Series en proformas / issued invoices
This commit is contained in:
parent
fd7252d77c
commit
db4fa55cc0
@ -20,6 +20,8 @@ docs/
|
|||||||
- [Estado de migración a identity](./architecture/identity-migration-status.md)
|
- [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)
|
- [Flujo frontend de auth y selección de empresa](./frontend/auth-and-company-selection.md)
|
||||||
- [Seed local de admin](./dev/seed-local-admin.sql)
|
- [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/auth`](../modules/auth/README.md)
|
||||||
- [README de `modules/identity`](../modules/identity/README.md)
|
- [README de `modules/identity`](../modules/identity/README.md)
|
||||||
- [README de `modules/companies`](../modules/companies/README.md)
|
- [README de `modules/companies`](../modules/companies/README.md)
|
||||||
|
|||||||
163
docs/customer-invoices/invoice-series.md
Normal file
163
docs/customer-invoices/invoice-series.md
Normal 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.
|
||||||
69
docs/dev/customer-invoice-series.sql
Normal file
69
docs/dev/customer-invoice-series.sql
Normal 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;
|
||||||
@ -1,2 +1,3 @@
|
|||||||
|
export * from "./invoice-series";
|
||||||
export * from "./issued-invoices";
|
export * from "./issued-invoices";
|
||||||
export * from "./proformas";
|
export * from "./proformas";
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./invoice-series-number-assigner.di";
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import type { IInvoiceSeriesRepository } from "../repositories";
|
||||||
|
import {
|
||||||
|
type IInvoiceSeriesNumberAssigner,
|
||||||
|
InvoiceSeriesNumberAssigner,
|
||||||
|
} from "../services";
|
||||||
|
|
||||||
|
export const buildInvoiceSeriesNumberAssigner = (
|
||||||
|
repository: IInvoiceSeriesRepository
|
||||||
|
): IInvoiceSeriesNumberAssigner => new InvoiceSeriesNumberAssigner(repository);
|
||||||
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./inactive-invoice-series.error";
|
||||||
|
export * from "./invoice-series-transaction-required.error";
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
export class InvoiceSeriesTransactionRequiredError extends Error {
|
||||||
|
public constructor() {
|
||||||
|
super("InvoiceSeries number assignment requires an active transaction");
|
||||||
|
this.name = "InvoiceSeriesTransactionRequiredError";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./di";
|
||||||
|
export * from "./errors";
|
||||||
|
export * from "./repositories";
|
||||||
|
export * from "./services";
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./invoice-series-repository.interface";
|
||||||
@ -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>>;
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./invoice-series-number-assigner";
|
||||||
@ -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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,18 +1,15 @@
|
|||||||
|
import type { IInvoiceSeriesNumberAssigner } from "../../invoice-series";
|
||||||
import type { IIssuedInvoiceRepository } from "../repositories";
|
import type { IIssuedInvoiceRepository } from "../repositories";
|
||||||
import {
|
import { type IIssuedInvoiceCreator, IssuedInvoiceCreator } from "../services";
|
||||||
type IIssuedInvoiceCreator,
|
|
||||||
type IIssuedInvoiceNumberGenerator,
|
|
||||||
IssuedInvoiceCreator,
|
|
||||||
} from "../services";
|
|
||||||
|
|
||||||
export function buildIssuedInvoiceCreator(params: {
|
export function buildIssuedInvoiceCreator(params: {
|
||||||
numberService: IIssuedInvoiceNumberGenerator;
|
invoiceSeriesNumberAssigner: IInvoiceSeriesNumberAssigner;
|
||||||
repository: IIssuedInvoiceRepository;
|
repository: IIssuedInvoiceRepository;
|
||||||
}): IIssuedInvoiceCreator {
|
}): IIssuedInvoiceCreator {
|
||||||
const { numberService, repository } = params;
|
const { invoiceSeriesNumberAssigner, repository } = params;
|
||||||
|
|
||||||
return new IssuedInvoiceCreator({
|
return new IssuedInvoiceCreator({
|
||||||
repository,
|
repository,
|
||||||
numberService,
|
invoiceSeriesNumberAssigner,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,5 @@ export * from "./issued-invoice-document-generator.interface";
|
|||||||
export * from "./issued-invoice-document-metadata-factory";
|
export * from "./issued-invoice-document-metadata-factory";
|
||||||
export * from "./issued-invoice-document-properties-factory";
|
export * from "./issued-invoice-document-properties-factory";
|
||||||
export * from "./issued-invoice-finder";
|
export * from "./issued-invoice-finder";
|
||||||
export * from "./issued-invoice-number-generator.interface";
|
|
||||||
export * from "./issued-invoice-public-services.interface";
|
export * from "./issued-invoice-public-services.interface";
|
||||||
export * from "./proforma-to-issued-invoice-props-converter";
|
export * from "./proforma-to-issued-invoice-props-converter";
|
||||||
|
|||||||
@ -2,10 +2,9 @@ import type { UniqueID } from "@repo/rdx-ddd";
|
|||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import { type IIssuedInvoiceCreateProps, IssuedInvoice } from "../../../domain";
|
import { type IIssuedInvoiceCreateProps, IssuedInvoice } from "../../../domain";
|
||||||
|
import type { IInvoiceSeriesNumberAssigner } from "../../invoice-series";
|
||||||
import type { IIssuedInvoiceRepository } from "../repositories";
|
import type { IIssuedInvoiceRepository } from "../repositories";
|
||||||
|
|
||||||
import type { IIssuedInvoiceNumberGenerator } from "./issued-invoice-number-generator.interface";
|
|
||||||
|
|
||||||
export interface IIssuedInvoiceCreatorParams {
|
export interface IIssuedInvoiceCreatorParams {
|
||||||
companyId: UniqueID;
|
companyId: UniqueID;
|
||||||
id: UniqueID;
|
id: UniqueID;
|
||||||
@ -18,31 +17,33 @@ export type IIssuedInvoiceCreator = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type IssuedInvoiceCreatorDeps = {
|
type IssuedInvoiceCreatorDeps = {
|
||||||
numberService: IIssuedInvoiceNumberGenerator;
|
invoiceSeriesNumberAssigner: IInvoiceSeriesNumberAssigner;
|
||||||
repository: IIssuedInvoiceRepository;
|
repository: IIssuedInvoiceRepository;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class IssuedInvoiceCreator implements IIssuedInvoiceCreator {
|
export class IssuedInvoiceCreator implements IIssuedInvoiceCreator {
|
||||||
private readonly numberService: IIssuedInvoiceNumberGenerator;
|
private readonly invoiceSeriesNumberAssigner: IInvoiceSeriesNumberAssigner;
|
||||||
private readonly repository: IIssuedInvoiceRepository;
|
private readonly repository: IIssuedInvoiceRepository;
|
||||||
|
|
||||||
constructor(deps: IssuedInvoiceCreatorDeps) {
|
constructor(deps: IssuedInvoiceCreatorDeps) {
|
||||||
this.numberService = deps.numberService;
|
this.invoiceSeriesNumberAssigner = deps.invoiceSeriesNumberAssigner;
|
||||||
this.repository = deps.repository;
|
this.repository = deps.repository;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(params: IIssuedInvoiceCreatorParams): Promise<Result<IssuedInvoice, Error>> {
|
async create(params: IIssuedInvoiceCreatorParams): Promise<Result<IssuedInvoice, Error>> {
|
||||||
const { companyId, id, props, transaction } = params;
|
const { companyId, id, props, transaction } = params;
|
||||||
|
|
||||||
// 1. Obtener siguiente número
|
const numberResult = await this.invoiceSeriesNumberAssigner.assignNextNumber({
|
||||||
const { series } = props;
|
companyId,
|
||||||
const numberResult = await this.numberService.getNextForCompany(companyId, series, transaction);
|
invoiceSeriesCode: props.series,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
if (numberResult.isFailure) {
|
if (numberResult.isFailure) {
|
||||||
return Result.fail(numberResult.error);
|
return Result.fail(numberResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const invoiceNumber = numberResult.data;
|
const invoiceNumber = numberResult.data.invoiceNumber;
|
||||||
|
|
||||||
const invoiceResult = IssuedInvoice.create({ ...props, invoiceNumber, companyId }, id);
|
const invoiceResult = IssuedInvoice.create({ ...props, invoiceNumber, companyId }, id);
|
||||||
|
|
||||||
|
|||||||
@ -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>>;
|
|
||||||
}
|
|
||||||
@ -15,8 +15,10 @@ import {
|
|||||||
} from "../../../domain";
|
} from "../../../domain";
|
||||||
import type { ProformaIssueReadModel } from "../models";
|
import type { ProformaIssueReadModel } from "../models";
|
||||||
|
|
||||||
|
export type ProformaToIssuedInvoiceCreateProps = Omit<IIssuedInvoiceCreateProps, "invoiceNumber">;
|
||||||
|
|
||||||
export interface IProformaToIssuedInvoiceConverter {
|
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 {
|
export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoiceConverter {
|
||||||
public toCreateProps(source: ProformaIssueReadModel): Result<IIssuedInvoiceCreateProps, Error> {
|
public toCreateProps(
|
||||||
|
source: ProformaIssueReadModel
|
||||||
|
): Result<ProformaToIssuedInvoiceCreateProps, Error> {
|
||||||
const { proforma } = source;
|
const { proforma } = source;
|
||||||
|
|
||||||
const recipientResult = this.resolveRecipient(proforma);
|
const recipientResult = this.resolveRecipient(proforma);
|
||||||
@ -62,8 +66,6 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
|
|||||||
series: proforma.series.getOrUndefined()!,
|
series: proforma.series.getOrUndefined()!,
|
||||||
linkedProformaId: proforma.id,
|
linkedProformaId: proforma.id,
|
||||||
|
|
||||||
invoiceNumber: proforma.invoiceNumber,
|
|
||||||
|
|
||||||
// La fecha de factura debe reflejar la emisión, no la fecha original de la proforma.
|
// La fecha de factura debe reflejar la emisión, no la fecha original de la proforma.
|
||||||
invoiceDate: UtcDate.today(),
|
invoiceDate: UtcDate.today(),
|
||||||
operationDate: proforma.operationDate,
|
operationDate: proforma.operationDate,
|
||||||
@ -105,7 +107,7 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
|
|||||||
totalAmount: proformaTotals.totalAmount,
|
totalAmount: proformaTotals.totalAmount,
|
||||||
|
|
||||||
verifactu: Maybe.none(),
|
verifactu: Maybe.none(),
|
||||||
} satisfies IIssuedInvoiceCreateProps);
|
} satisfies ProformaToIssuedInvoiceCreateProps);
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveRecipient(proforma: Proforma): Result<IssuedInvoiceRecipient, Error> {
|
private resolveRecipient(proforma: Proforma): Result<IssuedInvoiceRecipient, Error> {
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
// modules/customer-invoices/src/api/application/proformas/services/proforma-issuer.ts
|
// modules/customer-invoices/src/api/application/proformas/services/proforma-issuer.ts
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type { IIssuedInvoiceCreateProps } from "../../../domain";
|
|
||||||
import type {
|
import type {
|
||||||
IProformaToIssuedInvoiceConverter,
|
IProformaToIssuedInvoiceConverter,
|
||||||
|
ProformaToIssuedInvoiceCreateProps,
|
||||||
ProformaIssueReadModel,
|
ProformaIssueReadModel,
|
||||||
} from "../../issued-invoices";
|
} from "../../issued-invoices";
|
||||||
|
|
||||||
@ -12,7 +12,7 @@ export interface IProformaIssuerParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface IProformaIssuer {
|
export interface IProformaIssuer {
|
||||||
issueProforma(params: IProformaIssuerParams): Result<IIssuedInvoiceCreateProps, Error>;
|
issueProforma(params: IProformaIssuerParams): Result<ProformaToIssuedInvoiceCreateProps, Error>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ProformaIssuer implements IProformaIssuer {
|
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();
|
const issueResult = params.source.proforma.markAsIssued();
|
||||||
|
|
||||||
if (issueResult.isFailure) {
|
if (issueResult.isFailure) {
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
export * from "./common";
|
export * from "./common";
|
||||||
|
export * from "./invoice-series";
|
||||||
export * from "./issued-invoices";
|
export * from "./issued-invoices";
|
||||||
export * from "./proformas";
|
export * from "./proformas";
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./invoice-series.aggregate";
|
||||||
@ -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()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./aggregates";
|
||||||
|
export * from "./value-objects";
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./invoice-series-code.vo";
|
||||||
|
export * from "./invoice-series-next-number.vo";
|
||||||
|
export * from "./invoice-series-padding-length.vo";
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import customerInvoiceModelInit from "./models/customer-invoice.model";
|
import customerInvoiceModelInit from "./models/customer-invoice.model";
|
||||||
import customerInvoiceItemModelInit from "./models/customer-invoice-item.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 customerInvoiceTaxesModelInit from "./models/customer-invoice-tax.model";
|
||||||
import verifactuRecordModelInit from "./models/verifactu-record.model";
|
import verifactuRecordModelInit from "./models/verifactu-record.model";
|
||||||
|
|
||||||
@ -9,6 +10,7 @@ export * from "./models";
|
|||||||
export const models = [
|
export const models = [
|
||||||
customerInvoiceModelInit,
|
customerInvoiceModelInit,
|
||||||
customerInvoiceItemModelInit,
|
customerInvoiceItemModelInit,
|
||||||
|
customerInvoiceSeriesModelInit,
|
||||||
|
|
||||||
customerInvoiceTaxesModelInit,
|
customerInvoiceTaxesModelInit,
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
};
|
||||||
@ -1,4 +1,5 @@
|
|||||||
export * from "./customer-invoice.model";
|
export * from "./customer-invoice.model";
|
||||||
export * from "./customer-invoice-item.model";
|
export * from "./customer-invoice-item.model";
|
||||||
|
export * from "./customer-invoice-series.model";
|
||||||
export * from "./customer-invoice-tax.model";
|
export * from "./customer-invoice-tax.model";
|
||||||
export * from "./verifactu-record.model";
|
export * from "./verifactu-record.model";
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
export * from "./common";
|
export * from "./common";
|
||||||
|
export * from "./invoice-series";
|
||||||
export * from "./issued-invoices";
|
export * from "./issued-invoices";
|
||||||
export * from "./proformas";
|
export * from "./proformas";
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./invoice-series-repository.di";
|
||||||
@ -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);
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./di";
|
||||||
|
export * from "./persistence";
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./sequelize";
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./mappers";
|
||||||
|
export * from "./repositories";
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./invoice-series-domain.mapper";
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./invoice-series.repository";
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,2 @@
|
|||||||
export * from "./issued-invoice-number-generator.di";
|
|
||||||
export * from "./issued-invoice-public-services";
|
export * from "./issued-invoice-public-services";
|
||||||
export * from "./issued-invoices.di";
|
export * from "./issued-invoices.di";
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
import type { IIssuedInvoiceNumberGenerator } from "../../../application";
|
|
||||||
import { SequelizeIssuedInvoiceNumberGenerator } from "../persistence";
|
|
||||||
|
|
||||||
export const buildIssuedInvoiceNumberGenerator = (): IIssuedInvoiceNumberGenerator =>
|
|
||||||
new SequelizeIssuedInvoiceNumberGenerator();
|
|
||||||
@ -11,9 +11,10 @@ import {
|
|||||||
buildIssuedInvoiceFinder,
|
buildIssuedInvoiceFinder,
|
||||||
buildIssuedInvoiceSnapshotBuilders,
|
buildIssuedInvoiceSnapshotBuilders,
|
||||||
} from "../../../application/issued-invoices";
|
} from "../../../application/issued-invoices";
|
||||||
|
import { buildInvoiceSeriesNumberAssigner } from "../../../application/invoice-series";
|
||||||
|
|
||||||
import { buildIssuedInvoiceDocumentService } from "./issued-invoice-documents.di";
|
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 { buildIssuedInvoicePersistenceMappers } from "./issued-invoice-persistence-mappers.di";
|
||||||
import { buildIssuedInvoiceRepository } from "./issued-invoice-repositories.di";
|
import { buildIssuedInvoiceRepository } from "./issued-invoice-repositories.di";
|
||||||
import type { IssuedInvoicesInternalDeps } from "./issued-invoices.di";
|
import type { IssuedInvoicesInternalDeps } from "./issued-invoices.di";
|
||||||
@ -30,10 +31,11 @@ export function buildIssuedInvoicePublicServices(
|
|||||||
const persistenceMappers = buildIssuedInvoicePersistenceMappers(catalogs);
|
const persistenceMappers = buildIssuedInvoicePersistenceMappers(catalogs);
|
||||||
|
|
||||||
const repository = buildIssuedInvoiceRepository({ database, mappers: persistenceMappers });
|
const repository = buildIssuedInvoiceRepository({ database, mappers: persistenceMappers });
|
||||||
const numberService = buildIssuedInvoiceNumberGenerator();
|
const invoiceSeriesRepository = buildInvoiceSeriesRepository(database);
|
||||||
|
const invoiceSeriesNumberAssigner = buildInvoiceSeriesNumberAssigner(invoiceSeriesRepository);
|
||||||
|
|
||||||
// Application helpers
|
// Application helpers
|
||||||
const creator = buildIssuedInvoiceCreator({ numberService, repository });
|
const creator = buildIssuedInvoiceCreator({ invoiceSeriesNumberAssigner, repository });
|
||||||
const finder = buildIssuedInvoiceFinder(repository);
|
const finder = buildIssuedInvoiceFinder(repository);
|
||||||
const snapshotBuilders = buildIssuedInvoiceSnapshotBuilders();
|
const snapshotBuilders = buildIssuedInvoiceSnapshotBuilders();
|
||||||
const documentGeneratorPipeline = buildIssuedInvoiceDocumentService(params);
|
const documentGeneratorPipeline = buildIssuedInvoiceDocumentService(params);
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
export * from "./sequelize-issued-invoice-number-generator.service";
|
|
||||||
@ -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}`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -7,12 +7,13 @@ import { InvoiceNumber, type InvoiceSerie } from "../../../../../domain";
|
|||||||
import { CustomerInvoiceModel } from "../../../../common/persistence";
|
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 {
|
export class SequelizeProformaNumberGenerator implements IProformaNumberGenerator {
|
||||||
public async getNextForCompany(
|
public async getNextForCompany(
|
||||||
companyId: UniqueID,
|
companyId: UniqueID,
|
||||||
series: Maybe<InvoiceSerie>,
|
_series: Maybe<InvoiceSerie>,
|
||||||
transaction: Transaction
|
transaction: Transaction
|
||||||
): Promise<Result<InvoiceNumber, Error>> {
|
): Promise<Result<InvoiceNumber, Error>> {
|
||||||
const where: WhereOptions = {
|
const where: WhereOptions = {
|
||||||
@ -20,15 +21,6 @@ export class SequelizeProformaNumberGenerator implements IProformaNumberGenerato
|
|||||||
is_proforma: true,
|
is_proforma: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
series.match(
|
|
||||||
(serieVO) => {
|
|
||||||
where.series = serieVO.toString();
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
where.series = null;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const lastInvoice = await CustomerInvoiceModel.findOne({
|
const lastInvoice = await CustomerInvoiceModel.findOne({
|
||||||
attributes: ["invoice_number"],
|
attributes: ["invoice_number"],
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user