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)
|
||||
- [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)
|
||||
|
||||
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 "./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 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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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";
|
||||
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> {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./common";
|
||||
export * from "./invoice-series";
|
||||
export * from "./issued-invoices";
|
||||
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 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,
|
||||
|
||||
|
||||
@ -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-item.model";
|
||||
export * from "./customer-invoice-series.model";
|
||||
export * from "./customer-invoice-tax.model";
|
||||
export * from "./verifactu-record.model";
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./common";
|
||||
export * from "./invoice-series";
|
||||
export * from "./issued-invoices";
|
||||
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-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,
|
||||
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);
|
||||
|
||||
@ -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";
|
||||
|
||||
/**
|
||||
* 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"],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user