From db4fa55cc0c444e2ce82df1a4d02cf1f2b3e8e25 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 7 Jul 2026 22:21:50 +0200 Subject: [PATCH] Series en proformas / issued invoices --- docs/README.md | 2 + docs/customer-invoices/invoice-series.md | 163 ++++++++++++++++++ docs/dev/customer-invoice-series.sql | 69 ++++++++ .../src/api/application/index.ts | 1 + .../application/invoice-series/di/index.ts | 1 + .../di/invoice-series-number-assigner.di.ts | 9 + .../errors/inactive-invoice-series.error.ts | 6 + .../invoice-series/errors/index.ts | 2 + ...voice-series-transaction-required.error.ts | 6 + .../api/application/invoice-series/index.ts | 4 + .../invoice-series/repositories/index.ts | 1 + .../invoice-series-repository.interface.ts | 20 +++ .../invoice-series/services/index.ts | 1 + .../invoice-series-number-assigner.ts | 78 +++++++++ .../di/issued-invoice-creator.di.ts | 13 +- .../issued-invoices/services/index.ts | 1 - .../services/issued-invoice-creator.ts | 19 +- ...sued-invoice-number-generator.interface.ts | 19 -- ...forma-to-issued-invoice-props-converter.ts | 12 +- .../proformas/services/proforma-issuer.ts | 8 +- .../customer-invoices/src/api/domain/index.ts | 1 + .../domain/invoice-series/aggregates/index.ts | 1 + .../aggregates/invoice-series.aggregate.ts | 85 +++++++++ .../src/api/domain/invoice-series/index.ts | 2 + .../invoice-series/value-objects/index.ts | 3 + .../value-objects/invoice-series-code.vo.ts | 52 ++++++ .../invoice-series-next-number.vo.ts | 48 ++++++ .../invoice-series-padding-length.vo.ts | 48 ++++++ .../common/persistence/sequelize/index.ts | 2 + .../models/customer-invoice-series.model.ts | 104 +++++++++++ .../persistence/sequelize/models/index.ts | 1 + .../src/api/infrastructure/index.ts | 1 + .../infrastructure/invoice-series/di/index.ts | 1 + .../di/invoice-series-repository.di.ts | 7 + .../infrastructure/invoice-series/index.ts | 2 + .../invoice-series/persistence/index.ts | 1 + .../persistence/sequelize/index.ts | 2 + .../persistence/sequelize/mappers/index.ts | 1 + .../mappers/invoice-series-domain.mapper.ts | 75 ++++++++ .../sequelize/repositories/index.ts | 1 + .../repositories/invoice-series.repository.ts | 107 ++++++++++++ .../issued-invoices/di/index.ts | 1 - .../di/issued-invoice-number-generator.di.ts | 5 - .../di/issued-invoice-public-services.ts | 8 +- .../persistence/sequelize/services/index.ts | 1 - ...issued-invoice-number-generator.service.ts | 59 ------- ...elize-proforma-number-generator.service.ts | 14 +- 47 files changed, 943 insertions(+), 125 deletions(-) create mode 100644 docs/customer-invoices/invoice-series.md create mode 100644 docs/dev/customer-invoice-series.sql create mode 100644 modules/customer-invoices/src/api/application/invoice-series/di/index.ts create mode 100644 modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-number-assigner.di.ts create 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/index.ts create mode 100644 modules/customer-invoices/src/api/application/invoice-series/errors/invoice-series-transaction-required.error.ts create mode 100644 modules/customer-invoices/src/api/application/invoice-series/index.ts create mode 100644 modules/customer-invoices/src/api/application/invoice-series/repositories/index.ts create mode 100644 modules/customer-invoices/src/api/application/invoice-series/repositories/invoice-series-repository.interface.ts create mode 100644 modules/customer-invoices/src/api/application/invoice-series/services/index.ts create mode 100644 modules/customer-invoices/src/api/application/invoice-series/services/invoice-series-number-assigner.ts delete mode 100644 modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-number-generator.interface.ts create mode 100644 modules/customer-invoices/src/api/domain/invoice-series/aggregates/index.ts create mode 100644 modules/customer-invoices/src/api/domain/invoice-series/aggregates/invoice-series.aggregate.ts create mode 100644 modules/customer-invoices/src/api/domain/invoice-series/index.ts create mode 100644 modules/customer-invoices/src/api/domain/invoice-series/value-objects/index.ts create mode 100644 modules/customer-invoices/src/api/domain/invoice-series/value-objects/invoice-series-code.vo.ts create mode 100644 modules/customer-invoices/src/api/domain/invoice-series/value-objects/invoice-series-next-number.vo.ts create mode 100644 modules/customer-invoices/src/api/domain/invoice-series/value-objects/invoice-series-padding-length.vo.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/customer-invoice-series.model.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/invoice-series/di/index.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/invoice-series/di/invoice-series-repository.di.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/invoice-series/index.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/index.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/index.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/mappers/index.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/mappers/invoice-series-domain.mapper.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/repositories/index.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/repositories/invoice-series.repository.ts delete mode 100644 modules/customer-invoices/src/api/infrastructure/issued-invoices/di/issued-invoice-number-generator.di.ts delete mode 100644 modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/services/sequelize-issued-invoice-number-generator.service.ts diff --git a/docs/README.md b/docs/README.md index 253d66bf..1945180c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,8 @@ docs/ - [Estado de migración a identity](./architecture/identity-migration-status.md) - [Flujo frontend de auth y selección de empresa](./frontend/auth-and-company-selection.md) - [Seed local de admin](./dev/seed-local-admin.sql) +- [SQL manual de `customer_invoice_series`](./dev/customer-invoice-series.sql) +- [Decisión backend de `InvoiceSeries`](./customer-invoices/invoice-series.md) - [README de `modules/auth`](../modules/auth/README.md) - [README de `modules/identity`](../modules/identity/README.md) - [README de `modules/companies`](../modules/companies/README.md) diff --git a/docs/customer-invoices/invoice-series.md b/docs/customer-invoices/invoice-series.md new file mode 100644 index 00000000..b77b6fc6 --- /dev/null +++ b/docs/customer-invoices/invoice-series.md @@ -0,0 +1,163 @@ +# InvoiceSeries + +## Decisión consolidada + +El recurso backend para las series de facturación de cliente se llama: + +```txt +InvoiceSeries +``` + +Su tabla Sequelize asociada es: + +```txt +customer_invoice_series +``` + +No se usa ya un recurso backend genérico `series`, ni `document-series`, ni `issued-invoice-series`. + +## Alcance funcional + +`InvoiceSeries` representa la serie usada para numerar facturas emitidas de cliente. + +La proforma: + +```txt +- guarda una referencia legacy en Proforma.series +- ese valor corresponde a InvoiceSeries.code +- no consume numeración de factura emitida +``` + +## Modelo mínimo + +Campos activos en V1: + +```txt +id +company_id +code +next_number +padding_length +is_default +is_active +created_at +updated_at +``` + +No existen en V1: + +```txt +prefix +name +description +document_type +``` + +## Reglas de numeración + +`next_number` es obligatorio y es la fuente de verdad para asignar el siguiente número de factura emitida. + +El flujo de emisión desde proforma es: + +```txt +1. leer la proforma +2. tomar Proforma.series como InvoiceSeries.code +3. buscar InvoiceSeries por company_id + code +4. validar que la serie existe y está activa +5. bloquear la fila de customer_invoice_series dentro de la misma transacción +6. leer next_number +7. asignar ese número a la issued invoice +8. incrementar next_number +9. confirmar issued invoice + increment en una sola transacción +``` + +No se permite: + +```txt +- MAX(invoice_number) + 1 +- consumir numeración al crear o actualizar proformas +- separar el lock y el update en transacciones distintas +``` + +## Formato visible + +El formato visible deriva de: + +```txt +formatted_number = code + "-" + number.padStart(padding_length, "0") +``` + +Ejemplo: + +```txt +code = A +number = 23 +padding_length = 6 + +=> A-000023 +``` + +En V1 no existe `prefix`. +`code` actúa a la vez como identificador funcional y prefijo visible. + +## Integridad defensiva + +En `customer_invoice_series` debe existir: + +```sql +UNIQUE (company_id, code) +``` + +En `customer_invoices` la regla defensiva para numeración emitida es: + +```txt +company + series + invoice_number debe ser único +``` + +Actualmente el modelo Sequelize usa: + +```sql +UNIQUE (company_id, series, invoice_number, is_proforma) +``` + +Esto evita colisiones entre proformas y facturas emitidas mientras ambas comparten `customer_invoices`. + +## Seeds e inicialización + +No se ha añadido seed automático por empresa porque el repo no expone un sistema real de seeders versionados ni una estrategia fiable para inicializar todas las companies existentes. + +Regla operativa: + +```txt +cada company necesita al menos una InvoiceSeries activa antes de emitir facturas +``` + +Serie mínima recomendada: + +```txt +code = A +next_number = 1 +padding_length = 6 +is_default = true +is_active = true +``` + +## Estado técnico del repo + +En este repo no se localizó un framework de migraciones versionadas con `sequelize-cli`, `umzug` o equivalente. + +El arranque del servidor registra modelos Sequelize y sincroniza base de datos mediante: + +```txt +database.sync({ alter: true }) +``` + +Por eso el SQL manual asociado a `customer_invoice_series` vive en: + +[customer-invoice-series.sql](/Z:/docs/dev/customer-invoice-series.sql) + +Ese archivo debe integrarse en el pipeline real de despliegue DB cuando exista. + +## Frontend + +No se ha tocado frontend como parte de esta decisión. diff --git a/docs/dev/customer-invoice-series.sql b/docs/dev/customer-invoice-series.sql new file mode 100644 index 00000000..6c820048 --- /dev/null +++ b/docs/dev/customer-invoice-series.sql @@ -0,0 +1,69 @@ +-- Customer Invoices - InvoiceSeries +-- Estado del repo a fecha 2026-07-07: +-- - no hay framework de migraciones versionadas localizado; +-- - el servidor registra modelos Sequelize y usa database.sync(...) según syncMode. +-- Este SQL es un artefacto manual para integrar en el sistema real de despliegue DB +-- cuando exista un pipeline formal de migraciones. + +CREATE TABLE IF NOT EXISTS customer_invoice_series ( + id CHAR(36) NOT NULL, + company_id CHAR(36) NOT NULL, + code VARCHAR(10) NOT NULL, + next_number INT UNSIGNED NOT NULL, + padding_length SMALLINT UNSIGNED NOT NULL, + is_default TINYINT(1) NOT NULL DEFAULT 0, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + CONSTRAINT uq_customer_invoice_series_company_code UNIQUE (company_id, code), + KEY idx_customer_invoice_series_company_id (company_id), + KEY idx_customer_invoice_series_company_active (company_id, is_active), + CONSTRAINT chk_customer_invoice_series_next_number CHECK (next_number >= 1), + CONSTRAINT chk_customer_invoice_series_padding_length CHECK (padding_length >= 1) +); + +-- Seed automático: +-- No se incluye seed por empresa porque el repo no expone un sistema de seeders +-- ni una estrategia fiable para recorrer companies existentes desde SQL puro +-- sin acoplar el despliegue a un tenant concreto. +-- Requisito operativo: cada company necesita al menos una InvoiceSeries activa +-- antes de emitir facturas. + +-- Ejemplo manual por company: +-- INSERT INTO customer_invoice_series ( +-- id, +-- company_id, +-- code, +-- next_number, +-- padding_length, +-- is_default, +-- is_active, +-- created_at, +-- updated_at +-- ) VALUES ( +-- 'REEMPLAZAR-UUID', +-- 'REEMPLAZAR-COMPANY-ID', +-- 'A', +-- 1, +-- 6, +-- 1, +-- 1, +-- NOW(), +-- NOW() +-- ); + +-- Auditoría previa recomendada para la constraint de facturas emitidas: +-- confirma si existen duplicados por company + serie + número antes de forzar cambios +-- en entornos donde la tabla customer_invoices ya tenga datos históricos. +-- +-- SELECT +-- company_id, +-- series, +-- invoice_number, +-- is_proforma, +-- COUNT(*) AS duplicated_rows +-- FROM customer_invoices +-- WHERE deleted_at IS NULL +-- GROUP BY company_id, series, invoice_number, is_proforma +-- HAVING COUNT(*) > 1; diff --git a/modules/customer-invoices/src/api/application/index.ts b/modules/customer-invoices/src/api/application/index.ts index de2b09ca..e4a0d309 100644 --- a/modules/customer-invoices/src/api/application/index.ts +++ b/modules/customer-invoices/src/api/application/index.ts @@ -1,2 +1,3 @@ +export * from "./invoice-series"; export * from "./issued-invoices"; export * from "./proformas"; 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 new file mode 100644 index 00000000..0ba92c62 --- /dev/null +++ b/modules/customer-invoices/src/api/application/invoice-series/di/index.ts @@ -0,0 +1 @@ +export * from "./invoice-series-number-assigner.di"; diff --git a/modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-number-assigner.di.ts b/modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-number-assigner.di.ts new file mode 100644 index 00000000..fd1089a2 --- /dev/null +++ b/modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-number-assigner.di.ts @@ -0,0 +1,9 @@ +import type { IInvoiceSeriesRepository } from "../repositories"; +import { + type IInvoiceSeriesNumberAssigner, + InvoiceSeriesNumberAssigner, +} from "../services"; + +export const buildInvoiceSeriesNumberAssigner = ( + repository: IInvoiceSeriesRepository +): IInvoiceSeriesNumberAssigner => new InvoiceSeriesNumberAssigner(repository); 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 new file mode 100644 index 00000000..6d7e91a8 --- /dev/null +++ b/modules/customer-invoices/src/api/application/invoice-series/errors/inactive-invoice-series.error.ts @@ -0,0 +1,6 @@ +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 new file mode 100644 index 00000000..7c870314 --- /dev/null +++ b/modules/customer-invoices/src/api/application/invoice-series/errors/index.ts @@ -0,0 +1,2 @@ +export * from "./inactive-invoice-series.error"; +export * from "./invoice-series-transaction-required.error"; 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 new file mode 100644 index 00000000..55402b23 --- /dev/null +++ b/modules/customer-invoices/src/api/application/invoice-series/errors/invoice-series-transaction-required.error.ts @@ -0,0 +1,6 @@ +export class InvoiceSeriesTransactionRequiredError extends Error { + public constructor() { + super("InvoiceSeries number assignment requires an active transaction"); + this.name = "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 new file mode 100644 index 00000000..bfb90d7c --- /dev/null +++ b/modules/customer-invoices/src/api/application/invoice-series/index.ts @@ -0,0 +1,4 @@ +export * from "./di"; +export * from "./errors"; +export * from "./repositories"; +export * from "./services"; diff --git a/modules/customer-invoices/src/api/application/invoice-series/repositories/index.ts b/modules/customer-invoices/src/api/application/invoice-series/repositories/index.ts new file mode 100644 index 00000000..4dd2f740 --- /dev/null +++ b/modules/customer-invoices/src/api/application/invoice-series/repositories/index.ts @@ -0,0 +1 @@ +export * from "./invoice-series-repository.interface"; 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 new file mode 100644 index 00000000..ce61eff3 --- /dev/null +++ b/modules/customer-invoices/src/api/application/invoice-series/repositories/invoice-series-repository.interface.ts @@ -0,0 +1,20 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Maybe, Result } from "@repo/rdx-utils"; + +import type { InvoiceSeries, InvoiceSeriesCode } from "../../../domain"; + +export interface IInvoiceSeriesRepository { + findByCodeInCompany(params: { + companyId: UniqueID; + invoiceSeriesCode: InvoiceSeriesCode; + transaction?: unknown; + }): Promise, Error>>; + + findByCodeInCompanyForUpdate(params: { + companyId: UniqueID; + invoiceSeriesCode: InvoiceSeriesCode; + transaction: unknown; + }): Promise, Error>>; + + update(invoiceSeries: InvoiceSeries, transaction?: unknown): Promise>; +} 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 new file mode 100644 index 00000000..46387972 --- /dev/null +++ b/modules/customer-invoices/src/api/application/invoice-series/services/index.ts @@ -0,0 +1 @@ +export * from "./invoice-series-number-assigner"; 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 new file mode 100644 index 00000000..1b6bc5db --- /dev/null +++ b/modules/customer-invoices/src/api/application/invoice-series/services/invoice-series-number-assigner.ts @@ -0,0 +1,78 @@ +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, + InvoiceSeriesTransactionRequiredError, +} from "../errors"; +import type { IInvoiceSeriesRepository } from "../repositories"; + +export interface InvoiceSeriesAssignment { + invoiceNumber: InvoiceNumber; + formattedNumber: string; + invoiceSeriesId: string; + invoiceSeriesCode: string; +} + +export interface InvoiceSeriesNumberAssignerParams { + companyId: UniqueID; + invoiceSeriesCode: InvoiceSeriesCode; + transaction: unknown; +} + +export interface IInvoiceSeriesNumberAssigner { + assignNextNumber( + params: InvoiceSeriesNumberAssignerParams + ): Promise>; +} + +export class InvoiceSeriesNumberAssigner implements IInvoiceSeriesNumberAssigner { + public constructor(private readonly repository: IInvoiceSeriesRepository) {} + + public async assignNextNumber( + params: InvoiceSeriesNumberAssignerParams + ): Promise> { + if (!params.transaction) { + return Result.fail(new InvoiceSeriesTransactionRequiredError()); + } + + const invoiceSeriesResult = await this.repository.findByCodeInCompanyForUpdate(params); + + if (invoiceSeriesResult.isFailure) { + return Result.fail(invoiceSeriesResult.error); + } + + if (invoiceSeriesResult.data.isNone()) { + return Result.fail( + new EntityNotFoundError("InvoiceSeries", "code", params.invoiceSeriesCode.toPrimitive()) + ); + } + + const invoiceSeries = invoiceSeriesResult.data.unwrap(); + + if (!invoiceSeries.isActive) { + return Result.fail(new InactiveInvoiceSeriesError(invoiceSeries.code.toPrimitive())); + } + + const invoiceNumberResult = invoiceSeries.assignNextInvoiceNumber(); + + if (invoiceNumberResult.isFailure) { + return Result.fail(invoiceNumberResult.error); + } + + const updateResult = await this.repository.update(invoiceSeries, params.transaction); + + if (updateResult.isFailure) { + return Result.fail(updateResult.error); + } + + return Result.ok({ + invoiceNumber: invoiceNumberResult.data, + formattedNumber: invoiceSeries.formatInvoiceNumber(invoiceNumberResult.data), + invoiceSeriesId: invoiceSeries.id.toString(), + invoiceSeriesCode: invoiceSeries.code.toPrimitive(), + }); + } +} diff --git a/modules/customer-invoices/src/api/application/issued-invoices/di/issued-invoice-creator.di.ts b/modules/customer-invoices/src/api/application/issued-invoices/di/issued-invoice-creator.di.ts index 5998c9fd..9f3cd468 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/di/issued-invoice-creator.di.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/di/issued-invoice-creator.di.ts @@ -1,18 +1,15 @@ +import type { IInvoiceSeriesNumberAssigner } from "../../invoice-series"; import type { IIssuedInvoiceRepository } from "../repositories"; -import { - type IIssuedInvoiceCreator, - type IIssuedInvoiceNumberGenerator, - IssuedInvoiceCreator, -} from "../services"; +import { type IIssuedInvoiceCreator, IssuedInvoiceCreator } from "../services"; export function buildIssuedInvoiceCreator(params: { - numberService: IIssuedInvoiceNumberGenerator; + invoiceSeriesNumberAssigner: IInvoiceSeriesNumberAssigner; repository: IIssuedInvoiceRepository; }): IIssuedInvoiceCreator { - const { numberService, repository } = params; + const { invoiceSeriesNumberAssigner, repository } = params; return new IssuedInvoiceCreator({ repository, - numberService, + invoiceSeriesNumberAssigner, }); } diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/index.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/index.ts index 77015e51..59f1c11c 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/services/index.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/services/index.ts @@ -3,6 +3,5 @@ export * from "./issued-invoice-document-generator.interface"; export * from "./issued-invoice-document-metadata-factory"; export * from "./issued-invoice-document-properties-factory"; export * from "./issued-invoice-finder"; -export * from "./issued-invoice-number-generator.interface"; export * from "./issued-invoice-public-services.interface"; export * from "./proforma-to-issued-invoice-props-converter"; 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 cc8e7e14..44cd5ee0 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 @@ -2,10 +2,9 @@ import type { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import { type IIssuedInvoiceCreateProps, IssuedInvoice } from "../../../domain"; +import type { IInvoiceSeriesNumberAssigner } from "../../invoice-series"; import type { IIssuedInvoiceRepository } from "../repositories"; -import type { IIssuedInvoiceNumberGenerator } from "./issued-invoice-number-generator.interface"; - export interface IIssuedInvoiceCreatorParams { companyId: UniqueID; id: UniqueID; @@ -18,31 +17,33 @@ export type IIssuedInvoiceCreator = { }; type IssuedInvoiceCreatorDeps = { - numberService: IIssuedInvoiceNumberGenerator; + invoiceSeriesNumberAssigner: IInvoiceSeriesNumberAssigner; repository: IIssuedInvoiceRepository; }; export class IssuedInvoiceCreator implements IIssuedInvoiceCreator { - private readonly numberService: IIssuedInvoiceNumberGenerator; + private readonly invoiceSeriesNumberAssigner: IInvoiceSeriesNumberAssigner; private readonly repository: IIssuedInvoiceRepository; constructor(deps: IssuedInvoiceCreatorDeps) { - this.numberService = deps.numberService; + this.invoiceSeriesNumberAssigner = deps.invoiceSeriesNumberAssigner; this.repository = deps.repository; } async create(params: IIssuedInvoiceCreatorParams): Promise> { const { companyId, id, props, transaction } = params; - // 1. Obtener siguiente número - const { series } = props; - const numberResult = await this.numberService.getNextForCompany(companyId, series, transaction); + const numberResult = await this.invoiceSeriesNumberAssigner.assignNextNumber({ + companyId, + invoiceSeriesCode: props.series, + transaction, + }); if (numberResult.isFailure) { return Result.fail(numberResult.error); } - const invoiceNumber = numberResult.data; + const invoiceNumber = numberResult.data.invoiceNumber; const invoiceResult = IssuedInvoice.create({ ...props, invoiceNumber, companyId }, id); diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-number-generator.interface.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-number-generator.interface.ts deleted file mode 100644 index 5b51c592..00000000 --- a/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-number-generator.interface.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { UniqueID } from "@repo/rdx-ddd"; -import type { Result } from "@repo/rdx-utils"; - -import type { InvoiceNumber, InvoiceSerie } from "../../../domain"; - -export interface IIssuedInvoiceNumberGenerator { - /** - * Devuelve el siguiente número de factura disponible para una empresa dentro de una "serie" de factura. - * - * @param companyId - Identificador de la empresa - * @param serie - Serie por la que buscar la última factura - * @param transaction - Transacción activa - */ - getNextForCompany( - companyId: UniqueID, - series: InvoiceSerie, - transaction: unknown - ): Promise>; -} diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts index df05c53d..f936d8c4 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts @@ -15,8 +15,10 @@ import { } from "../../../domain"; import type { ProformaIssueReadModel } from "../models"; +export type ProformaToIssuedInvoiceCreateProps = Omit; + export interface IProformaToIssuedInvoiceConverter { - toCreateProps(source: ProformaIssueReadModel): Result; + toCreateProps(source: ProformaIssueReadModel): Result; } /** @@ -27,7 +29,9 @@ export interface IProformaToIssuedInvoiceConverter { */ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoiceConverter { - public toCreateProps(source: ProformaIssueReadModel): Result { + public toCreateProps( + source: ProformaIssueReadModel + ): Result { const { proforma } = source; const recipientResult = this.resolveRecipient(proforma); @@ -62,8 +66,6 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic series: proforma.series.getOrUndefined()!, linkedProformaId: proforma.id, - invoiceNumber: proforma.invoiceNumber, - // La fecha de factura debe reflejar la emisión, no la fecha original de la proforma. invoiceDate: UtcDate.today(), operationDate: proforma.operationDate, @@ -105,7 +107,7 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic totalAmount: proformaTotals.totalAmount, verifactu: Maybe.none(), - } satisfies IIssuedInvoiceCreateProps); + } satisfies ProformaToIssuedInvoiceCreateProps); } private resolveRecipient(proforma: Proforma): Result { diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-issuer.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-issuer.ts index f6c06eb2..031d7a44 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-issuer.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-issuer.ts @@ -1,9 +1,9 @@ // modules/customer-invoices/src/api/application/proformas/services/proforma-issuer.ts import { Result } from "@repo/rdx-utils"; -import type { IIssuedInvoiceCreateProps } from "../../../domain"; import type { IProformaToIssuedInvoiceConverter, + ProformaToIssuedInvoiceCreateProps, ProformaIssueReadModel, } from "../../issued-invoices"; @@ -12,7 +12,7 @@ export interface IProformaIssuerParams { } export interface IProformaIssuer { - issueProforma(params: IProformaIssuerParams): Result; + issueProforma(params: IProformaIssuerParams): Result; } export class ProformaIssuer implements IProformaIssuer { @@ -22,7 +22,9 @@ export class ProformaIssuer implements IProformaIssuer { } ) {} - public issueProforma(params: IProformaIssuerParams): Result { + public issueProforma( + params: IProformaIssuerParams + ): Result { const issueResult = params.source.proforma.markAsIssued(); if (issueResult.isFailure) { diff --git a/modules/customer-invoices/src/api/domain/index.ts b/modules/customer-invoices/src/api/domain/index.ts index cece438d..90501b87 100644 --- a/modules/customer-invoices/src/api/domain/index.ts +++ b/modules/customer-invoices/src/api/domain/index.ts @@ -1,3 +1,4 @@ export * from "./common"; +export * from "./invoice-series"; export * from "./issued-invoices"; export * from "./proformas"; diff --git a/modules/customer-invoices/src/api/domain/invoice-series/aggregates/index.ts b/modules/customer-invoices/src/api/domain/invoice-series/aggregates/index.ts new file mode 100644 index 00000000..3fd16f39 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/invoice-series/aggregates/index.ts @@ -0,0 +1 @@ +export * from "./invoice-series.aggregate"; diff --git a/modules/customer-invoices/src/api/domain/invoice-series/aggregates/invoice-series.aggregate.ts b/modules/customer-invoices/src/api/domain/invoice-series/aggregates/invoice-series.aggregate.ts new file mode 100644 index 00000000..56e4b871 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/invoice-series/aggregates/invoice-series.aggregate.ts @@ -0,0 +1,85 @@ +import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { InvoiceNumber } from "../../common"; +import { + type InvoiceSeriesCode, + InvoiceSeriesNextNumber, + type InvoiceSeriesPaddingLength, +} from "../value-objects"; + +export interface IInvoiceSeriesCreateProps { + companyId: UniqueID; + code: InvoiceSeriesCode; + nextNumber: InvoiceSeriesNextNumber; + paddingLength: InvoiceSeriesPaddingLength; + isDefault: boolean; + isActive: boolean; +} + +export type InvoiceSeriesInternalProps = IInvoiceSeriesCreateProps; + +export class InvoiceSeries extends AggregateRoot { + protected constructor(props: InvoiceSeriesInternalProps, id?: UniqueID) { + super(props, id); + } + + public static create( + props: IInvoiceSeriesCreateProps, + id?: UniqueID + ): Result { + return Result.ok(new InvoiceSeries(props, id)); + } + + public static rehydrate(props: InvoiceSeriesInternalProps, id: UniqueID): InvoiceSeries { + return new InvoiceSeries(props, id); + } + + public get companyId(): UniqueID { + return this.props.companyId; + } + + public get code(): InvoiceSeriesCode { + return this.props.code; + } + + public get nextNumber(): InvoiceSeriesNextNumber { + return this.props.nextNumber; + } + + public get paddingLength(): InvoiceSeriesPaddingLength { + return this.props.paddingLength; + } + + public get isDefault(): boolean { + return this.props.isDefault; + } + + public get isActive(): boolean { + return this.props.isActive; + } + + public assignNextInvoiceNumber(): Result { + const currentNumber = this.props.nextNumber.value; + const formatted = String(currentNumber).padStart(this.props.paddingLength.value, "0"); + const invoiceNumberResult = InvoiceNumber.create(formatted); + + if (invoiceNumberResult.isFailure) { + return Result.fail(invoiceNumberResult.error); + } + + const nextNumberResult = InvoiceSeriesNextNumber.create(currentNumber + 1); + + if (nextNumberResult.isFailure) { + return Result.fail(nextNumberResult.error); + } + + this.props.nextNumber = nextNumberResult.data; + + return Result.ok(invoiceNumberResult.data); + } + + public formatInvoiceNumber(invoiceNumber: InvoiceNumber): string { + return `${this.code.toPrimitive()}-${invoiceNumber.toPrimitive()}`; + } +} diff --git a/modules/customer-invoices/src/api/domain/invoice-series/index.ts b/modules/customer-invoices/src/api/domain/invoice-series/index.ts new file mode 100644 index 00000000..3ad2d38e --- /dev/null +++ b/modules/customer-invoices/src/api/domain/invoice-series/index.ts @@ -0,0 +1,2 @@ +export * from "./aggregates"; +export * from "./value-objects"; diff --git a/modules/customer-invoices/src/api/domain/invoice-series/value-objects/index.ts b/modules/customer-invoices/src/api/domain/invoice-series/value-objects/index.ts new file mode 100644 index 00000000..4f458589 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/invoice-series/value-objects/index.ts @@ -0,0 +1,3 @@ +export * from "./invoice-series-code.vo"; +export * from "./invoice-series-next-number.vo"; +export * from "./invoice-series-padding-length.vo"; 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 new file mode 100644 index 00000000..77834dde --- /dev/null +++ b/modules/customer-invoices/src/api/domain/invoice-series/value-objects/invoice-series-code.vo.ts @@ -0,0 +1,52 @@ +import { DomainValidationError, ValueObject } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +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 + .string() + .trim() + .min(1, { message: "Invoice series code is required" }) + .max(InvoiceSeriesCode.MAX_LENGTH, { + message: `String must be at most ${InvoiceSeriesCode.MAX_LENGTH} characters long`, + }) + .safeParse(value); + } + + public static create(value: string) { + const validationResult = InvoiceSeriesCode.validate(value); + + if (!validationResult.success) { + return Result.fail( + new DomainValidationError( + InvoiceSeriesCode.ERROR_CODE, + InvoiceSeriesCode.FIELD, + validationResult.error.message + ) + ); + } + + return Result.ok(new InvoiceSeriesCode({ value: validationResult.data })); + } + + public getProps(): InvoiceSeriesCodeProps { + return this.props; + } + + public toString() { + return this.props.value; + } + + public toPrimitive() { + return this.props.value; + } +} 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 new file mode 100644 index 00000000..f834dd4e --- /dev/null +++ b/modules/customer-invoices/src/api/domain/invoice-series/value-objects/invoice-series-next-number.vo.ts @@ -0,0 +1,48 @@ +import { DomainValidationError, ValueObject } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +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() + .int() + .gte(1, { message: "Invoice series next number must be greater than or equal to 1" }) + .safeParse(value); + } + + public static create(value: number) { + const validationResult = InvoiceSeriesNextNumber.validate(value); + + if (!validationResult.success) { + return Result.fail( + new DomainValidationError( + InvoiceSeriesNextNumber.ERROR_CODE, + InvoiceSeriesNextNumber.FIELD, + validationResult.error.message + ) + ); + } + + return Result.ok(new InvoiceSeriesNextNumber({ value: validationResult.data })); + } + + public get value(): number { + return this.props.value; + } + + public toPrimitive() { + return this.props.value; + } + + getProps(): InvoiceSeriesNextNumberProps { + return this.props; + } +} diff --git a/modules/customer-invoices/src/api/domain/invoice-series/value-objects/invoice-series-padding-length.vo.ts b/modules/customer-invoices/src/api/domain/invoice-series/value-objects/invoice-series-padding-length.vo.ts new file mode 100644 index 00000000..b4d8952b --- /dev/null +++ b/modules/customer-invoices/src/api/domain/invoice-series/value-objects/invoice-series-padding-length.vo.ts @@ -0,0 +1,48 @@ +import { DomainValidationError, ValueObject } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +type InvoiceSeriesPaddingLengthProps = { + value: number; +}; + +export class InvoiceSeriesPaddingLength 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() + .int() + .gte(1, { message: "Invoice series padding length must be greater than or equal to 1" }) + .safeParse(value); + } + + public static create(value: number) { + const validationResult = InvoiceSeriesPaddingLength.validate(value); + + if (!validationResult.success) { + return Result.fail( + new DomainValidationError( + InvoiceSeriesPaddingLength.ERROR_CODE, + InvoiceSeriesPaddingLength.FIELD, + validationResult.error.message + ) + ); + } + + return Result.ok(new InvoiceSeriesPaddingLength({ value: validationResult.data })); + } + + public get value(): number { + return this.props.value; + } + + public toPrimitive() { + return this.props.value; + } + + getProps(): InvoiceSeriesPaddingLengthProps { + return this.props; + } +} diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/index.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/index.ts index 050ae799..f920c8db 100644 --- a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/index.ts @@ -1,5 +1,6 @@ import customerInvoiceModelInit from "./models/customer-invoice.model"; import customerInvoiceItemModelInit from "./models/customer-invoice-item.model"; +import customerInvoiceSeriesModelInit from "./models/customer-invoice-series.model"; import customerInvoiceTaxesModelInit from "./models/customer-invoice-tax.model"; import verifactuRecordModelInit from "./models/verifactu-record.model"; @@ -9,6 +10,7 @@ export * from "./models"; export const models = [ customerInvoiceModelInit, customerInvoiceItemModelInit, + customerInvoiceSeriesModelInit, customerInvoiceTaxesModelInit, diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/customer-invoice-series.model.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/customer-invoice-series.model.ts new file mode 100644 index 00000000..4d98bbac --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/customer-invoice-series.model.ts @@ -0,0 +1,104 @@ +import { + type CreationOptional, + DataTypes, + type InferAttributes, + type InferCreationAttributes, + Model, + type Sequelize, +} from "sequelize"; + +export class CustomerInvoiceSeriesModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: string; + declare company_id: string; + declare code: string; + declare next_number: number; + declare padding_length: number; + declare is_default: CreationOptional; + declare is_active: CreationOptional; + declare created_at: CreationOptional; + declare updated_at: CreationOptional; +} + +export default (database: Sequelize) => { + CustomerInvoiceSeriesModel.init( + { + id: { + type: DataTypes.UUID, + primaryKey: true, + }, + company_id: { + type: DataTypes.UUID, + allowNull: false, + }, + code: { + type: DataTypes.STRING(10), + allowNull: false, + }, + next_number: { + type: DataTypes.INTEGER.UNSIGNED, + allowNull: false, + }, + padding_length: { + type: DataTypes.SMALLINT.UNSIGNED, + allowNull: false, + }, + is_default: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + is_active: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + }, + }, + { + sequelize: database, + modelName: "CustomerInvoiceSeriesModel", + tableName: "customer_invoice_series", + + underscored: true, + paranoid: false, + timestamps: true, + + createdAt: "created_at", + updatedAt: "updated_at", + + indexes: [ + { + name: "idx_customer_invoice_series_company_id", + fields: ["company_id"], + }, + { + name: "idx_customer_invoice_series_company_active", + fields: ["company_id", "is_active"], + }, + { + name: "uq_customer_invoice_series_company_code", + fields: ["company_id", "code"], + unique: true, + }, + ], + + whereMergeStrategy: "and", // <- cómo tratar el merge de un scope + + defaultScope: {}, + + scopes: {}, + } + ); + + return CustomerInvoiceSeriesModel; +}; diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/index.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/index.ts index 137f8afb..8c5bafa9 100644 --- a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/index.ts @@ -1,4 +1,5 @@ export * from "./customer-invoice.model"; export * from "./customer-invoice-item.model"; +export * from "./customer-invoice-series.model"; export * from "./customer-invoice-tax.model"; export * from "./verifactu-record.model"; diff --git a/modules/customer-invoices/src/api/infrastructure/index.ts b/modules/customer-invoices/src/api/infrastructure/index.ts index cece438d..90501b87 100644 --- a/modules/customer-invoices/src/api/infrastructure/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/index.ts @@ -1,3 +1,4 @@ export * from "./common"; +export * from "./invoice-series"; export * from "./issued-invoices"; export * from "./proformas"; 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 new file mode 100644 index 00000000..0f9cfcfc --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/invoice-series/di/index.ts @@ -0,0 +1 @@ +export * from "./invoice-series-repository.di"; diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/di/invoice-series-repository.di.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/di/invoice-series-repository.di.ts new file mode 100644 index 00000000..b2738be5 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/invoice-series/di/invoice-series-repository.di.ts @@ -0,0 +1,7 @@ +import type { Sequelize } from "sequelize"; + +import { SequelizeInvoiceSeriesRepository } from "../persistence"; +import { InvoiceSeriesDomainMapper } from "../persistence/sequelize/mappers"; + +export const buildInvoiceSeriesRepository = (database: Sequelize) => + new SequelizeInvoiceSeriesRepository(new InvoiceSeriesDomainMapper(), database); diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/index.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/index.ts new file mode 100644 index 00000000..96e04610 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/invoice-series/index.ts @@ -0,0 +1,2 @@ +export * from "./di"; +export * from "./persistence"; diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/index.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/index.ts new file mode 100644 index 00000000..62f8ac11 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/index.ts @@ -0,0 +1 @@ +export * from "./sequelize"; diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/index.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/index.ts new file mode 100644 index 00000000..bad13e0e --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/index.ts @@ -0,0 +1,2 @@ +export * from "./mappers"; +export * from "./repositories"; diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/mappers/index.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/mappers/index.ts new file mode 100644 index 00000000..368fe8ca --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/mappers/index.ts @@ -0,0 +1 @@ +export * from "./invoice-series-domain.mapper"; 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 new file mode 100644 index 00000000..665957c0 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/mappers/invoice-series-domain.mapper.ts @@ -0,0 +1,75 @@ +import { + UniqueID, + ValidationErrorCollection, + type ValidationErrorDetail, + extractOrPushError, +} from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { + InvoiceSeries, + InvoiceSeriesCode, + type InvoiceSeriesInternalProps, + InvoiceSeriesNextNumber, + InvoiceSeriesPaddingLength, +} from "../../../../../domain"; +import type { CustomerInvoiceSeriesModel } from "../../../../common"; + +export class InvoiceSeriesDomainMapper { + public mapToDomain(raw: CustomerInvoiceSeriesModel): Result { + const errors: ValidationErrorDetail[] = []; + + 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 + ); + + if (errors.length > 0) { + return Result.fail( + new ValidationErrorCollection("Invoice series mapping failed [mapToDomain]", errors) + ); + } + + const props: InvoiceSeriesInternalProps = { + companyId: companyId!, + code: code!, + nextNumber: nextNumber!, + paddingLength: paddingLength!, + isDefault: raw.is_default, + isActive: raw.is_active, + }; + + return Result.ok(InvoiceSeries.rehydrate(props, id!)); + } + + public mapToPersistence(source: InvoiceSeries) { + return Result.ok({ + id: source.id.toString(), + company_id: source.companyId.toString(), + code: source.code.toPrimitive(), + next_number: source.nextNumber.toPrimitive(), + padding_length: source.paddingLength.toPrimitive(), + is_default: source.isDefault, + is_active: source.isActive, + }); + } + + public mapOptionalToDomain( + raw: CustomerInvoiceSeriesModel | null + ): Result, Error> { + if (!raw) { + return Result.ok(Maybe.none()); + } + + return this.mapToDomain(raw).map((invoiceSeries) => Maybe.some(invoiceSeries)); + } +} diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/repositories/index.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/repositories/index.ts new file mode 100644 index 00000000..758a4658 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/repositories/index.ts @@ -0,0 +1 @@ +export * from "./invoice-series.repository"; 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 new file mode 100644 index 00000000..bea43dd8 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/repositories/invoice-series.repository.ts @@ -0,0 +1,107 @@ +import { + InfrastructureRepositoryError, + SequelizeRepository, + translateSequelizeError, +} from "@erp/core/api"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import type { Sequelize, Transaction } from "sequelize"; + +import type { IInvoiceSeriesRepository } from "../../../../../application"; +import type { InvoiceSeries, InvoiceSeriesCode } from "../../../../../domain"; +import { CustomerInvoiceSeriesModel } from "../../../../common"; +import type { InvoiceSeriesDomainMapper } from "../mappers"; + +export class SequelizeInvoiceSeriesRepository + extends SequelizeRepository + implements IInvoiceSeriesRepository +{ + public constructor( + private readonly domainMapper: InvoiceSeriesDomainMapper, + database: Sequelize + ) { + super({ database }); + } + + public async findByCodeInCompany(params: { + companyId: UniqueID; + invoiceSeriesCode: InvoiceSeriesCode; + transaction?: unknown; + }) { + try { + const row = await CustomerInvoiceSeriesModel.findOne({ + where: { + company_id: params.companyId.toString(), + code: params.invoiceSeriesCode.toPrimitive(), + }, + transaction: params.transaction as Transaction | undefined, + }); + + return this.domainMapper.mapOptionalToDomain(row); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + public async findByCodeInCompanyForUpdate(params: { + companyId: UniqueID; + invoiceSeriesCode: InvoiceSeriesCode; + transaction: unknown; + }) { + try { + const transaction = params.transaction as Transaction; + + if (!transaction) { + return Result.fail( + new InfrastructureRepositoryError( + "InvoiceSeries row lock requires an active transaction" + ) + ); + } + + const row = await CustomerInvoiceSeriesModel.findOne({ + where: { + company_id: params.companyId.toString(), + code: params.invoiceSeriesCode.toPrimitive(), + }, + transaction, + lock: transaction.LOCK.UPDATE, + }); + + return this.domainMapper.mapOptionalToDomain(row); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + public async update( + invoiceSeries: InvoiceSeries, + transaction?: unknown + ): Promise> { + try { + const dtoResult = this.domainMapper.mapToPersistence(invoiceSeries); + + if (dtoResult.isFailure) { + return Result.fail(dtoResult.error); + } + + const { id, ...payload } = dtoResult.data; + const [affectedRows] = await CustomerInvoiceSeriesModel.update(payload, { + where: { id }, + transaction: transaction as Transaction | undefined, + }); + + if (affectedRows !== 1) { + return Result.fail( + new InfrastructureRepositoryError( + `InvoiceSeries ${id} could not be updated or no longer exists` + ) + ); + } + + return Result.ok(); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } +} diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/di/index.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/di/index.ts index 849cce19..7a95685d 100644 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/di/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/di/index.ts @@ -1,3 +1,2 @@ -export * from "./issued-invoice-number-generator.di"; export * from "./issued-invoice-public-services"; export * from "./issued-invoices.di"; diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/di/issued-invoice-number-generator.di.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/di/issued-invoice-number-generator.di.ts deleted file mode 100644 index 4d3bddf2..00000000 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/di/issued-invoice-number-generator.di.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { IIssuedInvoiceNumberGenerator } from "../../../application"; -import { SequelizeIssuedInvoiceNumberGenerator } from "../persistence"; - -export const buildIssuedInvoiceNumberGenerator = (): IIssuedInvoiceNumberGenerator => - new SequelizeIssuedInvoiceNumberGenerator(); diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/di/issued-invoice-public-services.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/di/issued-invoice-public-services.ts index 027a728a..563b890f 100644 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/di/issued-invoice-public-services.ts +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/di/issued-invoice-public-services.ts @@ -11,9 +11,10 @@ import { buildIssuedInvoiceFinder, buildIssuedInvoiceSnapshotBuilders, } from "../../../application/issued-invoices"; +import { buildInvoiceSeriesNumberAssigner } from "../../../application/invoice-series"; import { buildIssuedInvoiceDocumentService } from "./issued-invoice-documents.di"; -import { buildIssuedInvoiceNumberGenerator } from "./issued-invoice-number-generator.di"; +import { buildInvoiceSeriesRepository } from "../../invoice-series"; import { buildIssuedInvoicePersistenceMappers } from "./issued-invoice-persistence-mappers.di"; import { buildIssuedInvoiceRepository } from "./issued-invoice-repositories.di"; import type { IssuedInvoicesInternalDeps } from "./issued-invoices.di"; @@ -30,10 +31,11 @@ export function buildIssuedInvoicePublicServices( const persistenceMappers = buildIssuedInvoicePersistenceMappers(catalogs); const repository = buildIssuedInvoiceRepository({ database, mappers: persistenceMappers }); - const numberService = buildIssuedInvoiceNumberGenerator(); + const invoiceSeriesRepository = buildInvoiceSeriesRepository(database); + const invoiceSeriesNumberAssigner = buildInvoiceSeriesNumberAssigner(invoiceSeriesRepository); // Application helpers - const creator = buildIssuedInvoiceCreator({ numberService, repository }); + const creator = buildIssuedInvoiceCreator({ invoiceSeriesNumberAssigner, repository }); const finder = buildIssuedInvoiceFinder(repository); const snapshotBuilders = buildIssuedInvoiceSnapshotBuilders(); const documentGeneratorPipeline = buildIssuedInvoiceDocumentService(params); diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/services/index.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/services/index.ts index da9443af..e69de29b 100644 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/services/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/services/index.ts @@ -1 +0,0 @@ -export * from "./sequelize-issued-invoice-number-generator.service"; diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/services/sequelize-issued-invoice-number-generator.service.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/services/sequelize-issued-invoice-number-generator.service.ts deleted file mode 100644 index 1bbb7428..00000000 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/services/sequelize-issued-invoice-number-generator.service.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { UniqueID } from "@repo/rdx-ddd"; -import { Result } from "@repo/rdx-utils"; -import { type Transaction, type WhereOptions, literal } from "sequelize"; - -import type { IIssuedInvoiceNumberGenerator } from "../../../../../application/issued-invoices"; -import { InvoiceNumber, type InvoiceSerie } from "../../../../../domain"; -import { CustomerInvoiceModel } from "../../../../common/persistence"; - -/** - * Generador de números de factura - */ -export class SequelizeIssuedInvoiceNumberGenerator implements IIssuedInvoiceNumberGenerator { - public async getNextForCompany( - companyId: UniqueID, - series: InvoiceSerie, - transaction: Transaction - ): Promise> { - const where: WhereOptions = { - company_id: companyId.toString(), - is_proforma: false, - }; - - where.series = series.toString(); - - try { - const lastInvoice = await CustomerInvoiceModel.findOne({ - attributes: ["invoice_number"], - where, - // Orden numérico real: CAST(... AS UNSIGNED) - order: [literal("CAST(invoice_number AS UNSIGNED) DESC")], - transaction, - raw: true, - // Bloqueo opcional para evitar carreras si estás dentro de una TX - lock: transaction.LOCK.UPDATE, // requiere InnoDB y TX abierta - }); - - let nextValue = "0001"; // valor inicial por defecto - - if (lastInvoice) { - const current = Number(lastInvoice.invoice_number); - const next = Number.isFinite(current) && current > 0 ? current + 1 : 1; - nextValue = String(next).padStart(4, "0"); - } - - const numberResult = InvoiceNumber.create(nextValue); - if (numberResult.isFailure) { - return Result.fail(numberResult.error); - } - - return Result.ok(numberResult.data); - } catch (error) { - return Result.fail( - new Error( - `Error generating invoice number for company ${companyId}: ${(error as Error).message}` - ) - ); - } - } -} diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/services/sequelize-proforma-number-generator.service.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/services/sequelize-proforma-number-generator.service.ts index d109cf9e..509000e6 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/services/sequelize-proforma-number-generator.service.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/services/sequelize-proforma-number-generator.service.ts @@ -7,12 +7,13 @@ import { InvoiceNumber, type InvoiceSerie } from "../../../../../domain"; import { CustomerInvoiceModel } from "../../../../common/persistence"; /** - * Generador de números de factura + * Genera numeración de proformas a nivel de empresa. + * La serie elegida en la proforma solo referencia la futura InvoiceSeries. */ export class SequelizeProformaNumberGenerator implements IProformaNumberGenerator { public async getNextForCompany( companyId: UniqueID, - series: Maybe, + _series: Maybe, transaction: Transaction ): Promise> { const where: WhereOptions = { @@ -20,15 +21,6 @@ export class SequelizeProformaNumberGenerator implements IProformaNumberGenerato is_proforma: true, }; - series.match( - (serieVO) => { - where.series = serieVO.toString(); - }, - () => { - where.series = null; - } - ); - try { const lastInvoice = await CustomerInvoiceModel.findOne({ attributes: ["invoice_number"],