Series en proformas / issued invoices

This commit is contained in:
David Arranz 2026-07-07 22:21:50 +02:00
parent fd7252d77c
commit db4fa55cc0
47 changed files with 943 additions and 125 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,9 @@
import type { IInvoiceSeriesRepository } from "../repositories";
import {
type IInvoiceSeriesNumberAssigner,
InvoiceSeriesNumberAssigner,
} from "../services";
export const buildInvoiceSeriesNumberAssigner = (
repository: IInvoiceSeriesRepository
): IInvoiceSeriesNumberAssigner => new InvoiceSeriesNumberAssigner(repository);

View File

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

View File

@ -0,0 +1,2 @@
export * from "./inactive-invoice-series.error";
export * from "./invoice-series-transaction-required.error";

View File

@ -0,0 +1,6 @@
export class InvoiceSeriesTransactionRequiredError extends Error {
public constructor() {
super("InvoiceSeries number assignment requires an active transaction");
this.name = "InvoiceSeriesTransactionRequiredError";
}
}

View File

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

View File

@ -0,0 +1 @@
export * from "./invoice-series-repository.interface";

View File

@ -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<Result<Maybe<InvoiceSeries>, Error>>;
findByCodeInCompanyForUpdate(params: {
companyId: UniqueID;
invoiceSeriesCode: InvoiceSeriesCode;
transaction: unknown;
}): Promise<Result<Maybe<InvoiceSeries>, Error>>;
update(invoiceSeries: InvoiceSeries, transaction?: unknown): Promise<Result<void, Error>>;
}

View File

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

View File

@ -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<Result<InvoiceSeriesAssignment, Error>>;
}
export class InvoiceSeriesNumberAssigner implements IInvoiceSeriesNumberAssigner {
public constructor(private readonly repository: IInvoiceSeriesRepository) {}
public async assignNextNumber(
params: InvoiceSeriesNumberAssignerParams
): Promise<Result<InvoiceSeriesAssignment, Error>> {
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(),
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -15,8 +15,10 @@ import {
} from "../../../domain";
import type { ProformaIssueReadModel } from "../models";
export type ProformaToIssuedInvoiceCreateProps = Omit<IIssuedInvoiceCreateProps, "invoiceNumber">;
export interface IProformaToIssuedInvoiceConverter {
toCreateProps(source: ProformaIssueReadModel): Result<IIssuedInvoiceCreateProps, Error>;
toCreateProps(source: ProformaIssueReadModel): Result<ProformaToIssuedInvoiceCreateProps, Error>;
}
/**
@ -27,7 +29,9 @@ export interface IProformaToIssuedInvoiceConverter {
*/
export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoiceConverter {
public toCreateProps(source: ProformaIssueReadModel): Result<IIssuedInvoiceCreateProps, Error> {
public toCreateProps(
source: ProformaIssueReadModel
): Result<ProformaToIssuedInvoiceCreateProps, Error> {
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<IssuedInvoiceRecipient, Error> {

View File

@ -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<IIssuedInvoiceCreateProps, Error>;
issueProforma(params: IProformaIssuerParams): Result<ProformaToIssuedInvoiceCreateProps, Error>;
}
export class ProformaIssuer implements IProformaIssuer {
@ -22,7 +22,9 @@ export class ProformaIssuer implements IProformaIssuer {
}
) {}
public issueProforma(params: IProformaIssuerParams): Result<IIssuedInvoiceCreateProps, Error> {
public issueProforma(
params: IProformaIssuerParams
): Result<ProformaToIssuedInvoiceCreateProps, Error> {
const issueResult = params.source.proforma.markAsIssued();
if (issueResult.isFailure) {

View File

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

View File

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

View File

@ -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<InvoiceSeriesInternalProps> {
protected constructor(props: InvoiceSeriesInternalProps, id?: UniqueID) {
super(props, id);
}
public static create(
props: IInvoiceSeriesCreateProps,
id?: UniqueID
): Result<InvoiceSeries, Error> {
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<InvoiceNumber, Error> {
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()}`;
}
}

View File

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

View File

@ -0,0 +1,3 @@
export * from "./invoice-series-code.vo";
export * from "./invoice-series-next-number.vo";
export * from "./invoice-series-padding-length.vo";

View File

@ -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<InvoiceSeriesCodeProps> {
private static readonly MAX_LENGTH = 10;
private static readonly FIELD = "invoiceSeriesCode";
private static readonly ERROR_CODE = "INVALID_INVOICE_SERIES_CODE";
private static validate(value: string) {
return z
.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;
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,104 @@
import {
type CreationOptional,
DataTypes,
type InferAttributes,
type InferCreationAttributes,
Model,
type Sequelize,
} from "sequelize";
export class CustomerInvoiceSeriesModel extends Model<
InferAttributes<CustomerInvoiceSeriesModel>,
InferCreationAttributes<CustomerInvoiceSeriesModel>
> {
declare id: string;
declare company_id: string;
declare code: string;
declare next_number: number;
declare padding_length: number;
declare is_default: CreationOptional<boolean>;
declare is_active: CreationOptional<boolean>;
declare created_at: CreationOptional<Date>;
declare updated_at: CreationOptional<Date>;
}
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;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
export * from "./mappers";
export * from "./repositories";

View File

@ -0,0 +1 @@
export * from "./invoice-series-domain.mapper";

View File

@ -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<InvoiceSeries, Error> {
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<Maybe<InvoiceSeries>, Error> {
if (!raw) {
return Result.ok(Maybe.none<InvoiceSeries>());
}
return this.mapToDomain(raw).map((invoiceSeries) => Maybe.some(invoiceSeries));
}
}

View File

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

View File

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

View File

@ -1,3 +1,2 @@
export * from "./issued-invoice-number-generator.di";
export * from "./issued-invoice-public-services";
export * from "./issued-invoices.di";

View File

@ -1,5 +0,0 @@
import type { IIssuedInvoiceNumberGenerator } from "../../../application";
import { SequelizeIssuedInvoiceNumberGenerator } from "../persistence";
export const buildIssuedInvoiceNumberGenerator = (): IIssuedInvoiceNumberGenerator =>
new SequelizeIssuedInvoiceNumberGenerator();

View File

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

View File

@ -1 +0,0 @@
export * from "./sequelize-issued-invoice-number-generator.service";

View File

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

View File

@ -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<InvoiceSerie>,
_series: Maybe<InvoiceSerie>,
transaction: Transaction
): Promise<Result<InvoiceNumber, Error>> {
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"],