Facturas de cliente

This commit is contained in:
David Arranz 2025-11-07 18:51:18 +01:00
parent 061ec30cbd
commit 0fcba918a6
44 changed files with 753 additions and 219 deletions

View File

@ -5,7 +5,7 @@
"scripts": { "scripts": {
"build": "tsup src/index.ts --config tsup.config.ts", "build": "tsup src/index.ts --config tsup.config.ts",
"start": "NODE_ENV=production node --env-file=.env.production dist/index.js", "start": "NODE_ENV=production node --env-file=.env.production dist/index.js",
"dev": "tsx watch src/index.ts", "dev": "node --import=tsx --watch src/index.ts",
"clean": "rimraf .turbo node_modules dist", "clean": "rimraf .turbo node_modules dist",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "biome lint --fix", "lint": "biome lint --fix",

View File

@ -1,4 +1,3 @@
import { globalErrorHandler } from "@erp/core/api";
import cors, { CorsOptions } from "cors"; import cors, { CorsOptions } from "cors";
import express, { Application } from "express"; import express, { Application } from "express";
import helmet from "helmet"; import helmet from "helmet";
@ -90,7 +89,7 @@ export function createApp(): Application {
// Gestión global de errores. // Gestión global de errores.
// Siempre al final de la cadena de middlewares // Siempre al final de la cadena de middlewares
// y después de las rutas. // y después de las rutas.
app.use(globalErrorHandler); //app.use(globalErrorHandler);
return app; return app;
} }

View File

@ -1,3 +1,3 @@
export * from "./customer-invoice-application.service";
export * from "./presenters"; export * from "./presenters";
export * from "./services";
export * from "./use-cases"; export * from "./use-cases";

View File

@ -47,6 +47,7 @@ export class CustomerInvoiceFullPresenter extends Presenter<
id: invoice.id.toString(), id: invoice.id.toString(),
company_id: invoice.companyId.toString(), company_id: invoice.companyId.toString(),
is_proforma: invoice.isProforma ? "true" : "false",
invoice_number: invoice.invoiceNumber.toString(), invoice_number: invoice.invoiceNumber.toString(),
status: invoice.status.toPrimitive(), status: invoice.status.toPrimitive(),
series: toEmptyString(invoice.series, (value) => value.toString()), series: toEmptyString(invoice.series, (value) => value.toString()),

View File

@ -5,15 +5,16 @@ import { Transaction } from "sequelize";
import { import {
CustomerInvoiceNumber, CustomerInvoiceNumber,
CustomerInvoiceSerie, CustomerInvoiceSerie,
CustomerInvoiceStatus,
ICustomerInvoiceNumberGenerator, ICustomerInvoiceNumberGenerator,
} from "../domain"; } from "../../domain";
import { import {
CustomerInvoice, CustomerInvoice,
CustomerInvoicePatchProps, CustomerInvoicePatchProps,
CustomerInvoiceProps, CustomerInvoiceProps,
} from "../domain/aggregates"; } from "../../domain/aggregates";
import { ICustomerInvoiceRepository } from "../domain/repositories"; import { ICustomerInvoiceRepository } from "../../domain/repositories";
import { CustomerInvoiceListDTO } from "../infrastructure"; import { CustomerInvoiceListDTO } from "../../infrastructure";
export class CustomerInvoiceApplicationService { export class CustomerInvoiceApplicationService {
constructor( constructor(
@ -67,28 +68,6 @@ export class CustomerInvoiceApplicationService {
return CustomerInvoice.create({ ...props, companyId }, invoiceId); return CustomerInvoice.create({ ...props, companyId }, invoiceId);
} }
/**
* Construye una factura issue a partir de una proforma.
*
* @param companyId - Identificador de la empresa a la que pertenece la factura.
* @param issueInvoiceId - Identificador UUID de la factura (opcional).
* @param proforma - La proforma de la cual se generará la issue
* @param pathcProps - otros props personalizados que se trasladarán a la issue
* @returns Result<CustomerInvoice, Error> - El agregado construido o un error si falla la creación.
*/
buildIssueInvoiceInCompany(
companyId: UniqueID,
proforma: CustomerInvoice,
patchProps: CustomerInvoicePatchProps
): Result<CustomerInvoice, Error> {
const proformaProps = proforma.getIssuedInvoiceProps();
return CustomerInvoice.create({
...proformaProps,
...patchProps,
companyId,
});
}
/** /**
* Guarda una nueva factura y devuelve la factura guardada. * Guarda una nueva factura y devuelve la factura guardada.
* *
@ -226,4 +205,28 @@ export class CustomerInvoiceApplicationService {
): Promise<Result<boolean, Error>> { ): Promise<Result<boolean, Error>> {
return this.repository.deleteByIdInCompany(companyId, invoiceId, transaction); return this.repository.deleteByIdInCompany(companyId, invoiceId, transaction);
} }
/**
*
* Actualiza el "status" de una proforma
*
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param proformaId - UUID de la factura a eliminar.
* @param newStatus - nuevo estado
* @param transaction - Transacción activa para la operación.
* @returns Result<boolean, Error>
*/
async updateProformaStatusByIdInCompany(
companyId: UniqueID,
proformaId: UniqueID,
newStatus: CustomerInvoiceStatus,
transaction?: Transaction
): Promise<Result<boolean, Error>> {
return this.repository.updateProformaStatusByIdInCompany(
companyId,
proformaId,
newStatus,
transaction
);
}
} }

View File

@ -0,0 +1 @@
export * from "./customer-invoice-application.service";

View File

@ -0,0 +1,65 @@
import { ITransactionManager } from "@erp/core/api";
import { ChangeStatusCustomerInvoiceByIdRequestDTO } from "@erp/customer-invoices/common";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { ProformaCustomerInvoiceDomainService } from "../../domain";
import { CustomerInvoiceApplicationService } from "../services";
type ChangeStatusCustomerInvoiceUseCaseInput = {
companyId: UniqueID;
proforma_id: string;
dto: ChangeStatusCustomerInvoiceByIdRequestDTO;
};
export class ChangeStatusCustomerInvoiceUseCase {
private readonly proformaDomainService: ProformaCustomerInvoiceDomainService;
constructor(
private readonly service: CustomerInvoiceApplicationService,
private readonly transactionManager: ITransactionManager
) {
this.proformaDomainService = new ProformaCustomerInvoiceDomainService();
}
public execute(params: ChangeStatusCustomerInvoiceUseCaseInput) {
const {
proforma_id,
companyId,
dto: { new_status },
} = params;
const idOrError = UniqueID.create(proforma_id);
if (idOrError.isFailure) return Result.fail(idOrError.error);
const proformaId = idOrError.data;
return this.transactionManager.complete(async (transaction) => {
try {
/** 1. Recuperamos la proforma */
const proformaResult = await this.service.getInvoiceByIdInCompany(
companyId,
proformaId,
transaction
);
if (proformaResult.isFailure) return Result.fail(proformaResult.error);
const proforma = proformaResult.data;
/** 2. Hacer el cambio de estado */
const transitionResult = await this.proformaDomainService.transition(proforma, new_status!);
if (transitionResult.isFailure) return Result.fail(transitionResult.error);
const updateResult = await this.service.updateProformaStatusByIdInCompany(
companyId,
proformaId,
transitionResult.data.status,
transaction
);
if (updateResult.isFailure) return Result.fail(updateResult.error);
return Result.ok();
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}

View File

@ -4,8 +4,8 @@ import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils"; import { Maybe, Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize"; import { Transaction } from "sequelize";
import { CreateCustomerInvoiceRequestDTO } from "../../../../common/dto"; import { CreateCustomerInvoiceRequestDTO } from "../../../../common/dto";
import { CustomerInvoiceApplicationService } from "../../customer-invoice-application.service";
import { CustomerInvoiceFullPresenter } from "../../presenters"; import { CustomerInvoiceFullPresenter } from "../../presenters";
import { CustomerInvoiceApplicationService } from "../../services/customer-invoice-application.service";
import { CreateCustomerInvoicePropsMapper } from "./map-dto-to-create-customer-invoice-props"; import { CreateCustomerInvoicePropsMapper } from "./map-dto-to-create-customer-invoice-props";
type CreateCustomerInvoiceUseCaseInput = { type CreateCustomerInvoiceUseCaseInput = {

View File

@ -1,7 +1,7 @@
import { EntityNotFoundError, ITransactionManager } from "@erp/core/api"; import { EntityNotFoundError, ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd"; import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { CustomerInvoiceApplicationService } from "../../application"; import { CustomerInvoiceApplicationService } from "../services";
type DeleteCustomerInvoiceUseCaseInput = { type DeleteCustomerInvoiceUseCaseInput = {
companyId: UniqueID; companyId: UniqueID;

View File

@ -1,8 +1,8 @@
import { IPresenterRegistry, ITransactionManager } from "@erp/core/api"; import { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd"; import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { CustomerInvoiceApplicationService } from "../../application";
import { CustomerInvoiceFullPresenter } from "../presenters/domain"; import { CustomerInvoiceFullPresenter } from "../presenters/domain";
import { CustomerInvoiceApplicationService } from "../services";
type GetCustomerInvoiceUseCaseInput = { type GetCustomerInvoiceUseCaseInput = {
companyId: UniqueID; companyId: UniqueID;

View File

@ -1,3 +1,4 @@
export * from "./change-status-customer-invoice.use-case";
export * from "./create"; export * from "./create";
export * from "./get-customer-invoice.use-case"; export * from "./get-customer-invoice.use-case";
export * from "./issue-customer-invoice.use-case"; export * from "./issue-customer-invoice.use-case";

View File

@ -1,9 +1,12 @@
import { ITransactionManager } from "@erp/core/api"; import { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
import { UniqueID, UtcDate } from "@repo/rdx-ddd"; import { UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { ProformaCannotBeConvertedToInvoiceError } from "../../domain"; import {
import { ProformaCanTranstionToIssuedSpecification } from "../../domain/specs"; IssueCustomerInvoiceDomainService,
import { CustomerInvoiceApplicationService } from "../customer-invoice-application.service"; ProformaCustomerInvoiceDomainService,
} from "../../domain";
import { CustomerInvoiceFullPresenter } from "../presenters";
import { CustomerInvoiceApplicationService } from "../services";
type IssueCustomerInvoiceUseCaseInput = { type IssueCustomerInvoiceUseCaseInput = {
companyId: UniqueID; companyId: UniqueID;
@ -20,21 +23,28 @@ type IssueCustomerInvoiceUseCaseInput = {
* - Persiste ambas dentro de la misma transacción * - Persiste ambas dentro de la misma transacción
*/ */
export class IssueCustomerInvoiceUseCase { export class IssueCustomerInvoiceUseCase {
private readonly issueDomainService: IssueCustomerInvoiceDomainService;
private readonly proformaDomainService: ProformaCustomerInvoiceDomainService;
constructor( constructor(
private readonly service: CustomerInvoiceApplicationService, private readonly service: CustomerInvoiceApplicationService,
private readonly transactionManager: ITransactionManager private readonly transactionManager: ITransactionManager,
) {} private readonly presenterRegistry: IPresenterRegistry
) {
this.issueDomainService = new IssueCustomerInvoiceDomainService();
this.proformaDomainService = new ProformaCustomerInvoiceDomainService();
}
public execute(params: IssueCustomerInvoiceUseCaseInput) { public execute(params: IssueCustomerInvoiceUseCaseInput) {
const { proforma_id, companyId } = params; const { proforma_id, companyId } = params;
const idOrError = UniqueID.create(proforma_id); const idOrError = UniqueID.create(proforma_id);
if (idOrError.isFailure) return Result.fail(idOrError.error);
if (idOrError.isFailure) {
return Result.fail(idOrError.error);
}
const proformaId = idOrError.data; const proformaId = idOrError.data;
const presenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice",
projection: "FULL",
}) as CustomerInvoiceFullPresenter;
return this.transactionManager.complete(async (transaction) => { return this.transactionManager.complete(async (transaction) => {
try { try {
@ -44,60 +54,47 @@ export class IssueCustomerInvoiceUseCase {
proformaId, proformaId,
transaction transaction
); );
if (proformaResult.isFailure) return Result.fail(proformaResult.error);
if (proformaResult.isFailure) {
return Result.fail(proformaResult.error);
}
const proforma = proformaResult.data; const proforma = proformaResult.data;
/** 2. Comprobamos que la proforma origen está aprovada para generar la factura */ /** 2. Generar nueva factura */
const isOk = new ProformaCanTranstionToIssuedSpecification();
if (!(await isOk.isSatisfiedBy(proforma))) {
return Result.fail(new ProformaCannotBeConvertedToInvoiceError(proformaId.toString()));
}
/** 3. Generar nueva factura */
const nextNumberResult = await this.service.getNextIssueInvoiceNumber( const nextNumberResult = await this.service.getNextIssueInvoiceNumber(
companyId, companyId,
proforma.series, proforma.series,
transaction transaction
); );
if (nextNumberResult.isFailure) { if (nextNumberResult.isFailure) return Result.fail(nextNumberResult.error);
return Result.fail(nextNumberResult.error);
}
const newIssueNumber = nextNumberResult.data; /** 4. Crear factura definitiva (dominio) */
const issuedInvoiceOrError = await this.issueDomainService.issueFromProforma(proforma, {
// props base obtenidas del agregado proforma issueNumber: nextNumberResult.data,
const issuedInvoiceOrError = this.service.buildIssueInvoiceInCompany(companyId, proforma, { issueDate: UtcDate.today(),
invoiceNumber: newIssueNumber,
invoiceDate: UtcDate.today(),
}); });
if (issuedInvoiceOrError.isFailure) return Result.fail(issuedInvoiceOrError.error);
if (issuedInvoiceOrError.isFailure) { /** 5. Guardar la nueva factura */
return Result.fail(issuedInvoiceOrError.error); const saveInvoiceResult = await this.service.createInvoiceInCompany(
}
const issuedInvoice = issuedInvoiceOrError.data;
/** 4. Persistencia */
await this.service.createInvoiceInCompany(companyId, issuedInvoice, transaction);
// actualizamos la proforma
const updatedProformaResult = proforma.asIssued();
if (updatedProformaResult.isFailure) {
return Result.fail(updatedProformaResult.error);
}
await this.service.updateInvoiceInCompany(
companyId, companyId,
updatedProformaResult.data, issuedInvoiceOrError.data,
transaction
);
if (saveInvoiceResult.isFailure) return Result.fail(saveInvoiceResult.error);
/** 6. Actualizar la proforma */
const closedProformaResult = await this.proformaDomainService.markAsIssued(proforma);
if (closedProformaResult.isFailure) return Result.fail(closedProformaResult.error);
const closedProforma = closedProformaResult.data;
/** 7. Guardar la proforma */
await this.service.updateProformaStatusByIdInCompany(
companyId,
proformaId,
closedProforma.status,
transaction transaction
); );
/** 5. Resultado */ const dto = presenter.toOutput(saveInvoiceResult.data);
return Result.ok(issuedInvoice); return Result.ok(dto);
} catch (error: unknown) { } catch (error: unknown) {
return Result.fail(error as Error); return Result.fail(error as Error);
} }

View File

@ -4,8 +4,8 @@ import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize"; import { Transaction } from "sequelize";
import { ListCustomerInvoicesResponseDTO } from "../../../common/dto"; import { ListCustomerInvoicesResponseDTO } from "../../../common/dto";
import { CustomerInvoiceApplicationService } from "../../application";
import { ListCustomerInvoicesPresenter } from "../presenters"; import { ListCustomerInvoicesPresenter } from "../presenters";
import { CustomerInvoiceApplicationService } from "../services";
type ListCustomerInvoicesUseCaseInput = { type ListCustomerInvoicesUseCaseInput = {
companyId: UniqueID; companyId: UniqueID;

View File

@ -1,7 +1,7 @@
import { IPresenterRegistry, ITransactionManager } from "@erp/core/api"; import { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd"; import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { CustomerInvoiceApplicationService } from "../../customer-invoice-application.service"; import { CustomerInvoiceApplicationService } from "../../services/customer-invoice-application.service";
import { CustomerInvoiceReportPDFPresenter } from "./reporter"; import { CustomerInvoiceReportPDFPresenter } from "./reporter";
type ReportCustomerInvoiceUseCaseInput = { type ReportCustomerInvoiceUseCaseInput = {

View File

@ -4,8 +4,8 @@ import { Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize"; import { Transaction } from "sequelize";
import { UpdateCustomerInvoiceByIdRequestDTO } from "../../../../common"; import { UpdateCustomerInvoiceByIdRequestDTO } from "../../../../common";
import { CustomerInvoicePatchProps } from "../../../domain"; import { CustomerInvoicePatchProps } from "../../../domain";
import { CustomerInvoiceApplicationService } from "../../customer-invoice-application.service";
import { CustomerInvoiceFullPresenter } from "../../presenters"; import { CustomerInvoiceFullPresenter } from "../../presenters";
import { CustomerInvoiceApplicationService } from "../../services/customer-invoice-application.service";
import { mapDTOToUpdateCustomerInvoicePatchProps } from "./map-dto-to-update-customer-invoice-props"; import { mapDTOToUpdateCustomerInvoicePatchProps } from "./map-dto-to-update-customer-invoice-props";
type UpdateCustomerInvoiceUseCaseInput = { type UpdateCustomerInvoiceUseCaseInput = {

View File

@ -65,18 +65,8 @@ export interface ICustomerInvoice {
hasRecipient: boolean; hasRecipient: boolean;
hasPaymentMethod: boolean; hasPaymentMethod: boolean;
_getSubtotalAmount(): InvoiceAmount;
getHeaderDiscountAmount(): InvoiceAmount;
getTaxableAmount(): InvoiceAmount;
getTaxesAmount(): InvoiceAmount;
getTotalAmount(): InvoiceAmount;
getTaxes(): InvoiceTaxTotal[]; getTaxes(): InvoiceTaxTotal[];
getProps(): CustomerInvoiceProps;
asIssued(): Result<CustomerInvoice, Error>;
getIssuedInvoiceProps(): CustomerInvoiceProps;
} }
export class CustomerInvoice export class CustomerInvoice
@ -94,15 +84,19 @@ export class CustomerInvoice
currencyCode: props.currencyCode, currencyCode: props.currencyCode,
}); });
} }
getHeaderDiscountAmount(): InvoiceAmount { getHeaderDiscountAmount(): InvoiceAmount {
throw new Error("Method not implemented."); throw new Error("Method not implemented.");
} }
getTaxableAmount(): InvoiceAmount { getTaxableAmount(): InvoiceAmount {
throw new Error("Method not implemented."); throw new Error("Method not implemented.");
} }
getTaxesAmount(): InvoiceAmount { getTaxesAmount(): InvoiceAmount {
throw new Error("Method not implemented."); throw new Error("Method not implemented.");
} }
getTotalAmount(): InvoiceAmount { getTotalAmount(): InvoiceAmount {
throw new Error("Method not implemented."); throw new Error("Method not implemented.");
} }
@ -348,21 +342,7 @@ export class CustomerInvoice
}; };
} }
public asIssued(): Result<CustomerInvoice, Error> { public getProps(): CustomerInvoiceProps {
const newProps: CustomerInvoiceProps = { return this.props;
...this.props,
status: CustomerInvoiceStatus.createIssued(),
};
return CustomerInvoice.create(newProps, this.id);
}
public getIssuedInvoiceProps(): CustomerInvoiceProps {
return {
...this.props,
isProforma: false,
proformaId: Maybe.some(this.id),
status: CustomerInvoiceStatus.createIssued(),
invoiceDate: UtcDate.today(),
};
} }
} }

View File

@ -6,7 +6,7 @@ import {
ItemDiscount, ItemDiscount,
ItemQuantity, ItemQuantity,
} from "../../value-objects"; } from "../../value-objects";
import { ItemTaxTotal, ItemTaxes } from "../item-taxes"; import { ItemTaxes, ItemTaxTotal } from "../item-taxes";
export interface CustomerInvoiceItemProps { export interface CustomerInvoiceItemProps {
description: Maybe<CustomerInvoiceItemDescription>; description: Maybe<CustomerInvoiceItemDescription>;

View File

@ -0,0 +1,31 @@
import { DomainError } from "@repo/rdx-ddd";
/**
* Error de dominio que indica que el documento no es una Proforma.
*
* @remarks
* - Se lanza cuando una operación requiere explícitamente una Proforma
*
* @public
*/
export class EntityIsNotProformaError extends DomainError {
/**
* Crea una instancia del error con el identificador de la Proforma.
*
* @param id - Identificador de la Proforma.
* @param options - Opciones nativas de Error (puedes pasar `cause`).
*/
constructor(id: string, options?: ErrorOptions) {
super(`Error. Entity with id '${id}' is not a proforma .`, options);
this.name = "EntityIsNotProformaError";
}
}
/**
* *Type guard* para `EntityIsNotProformaError`.
*
* @param e - Error desconocido
* @returns `true` si `e` es `EntityIsNotProformaError`
*/
export const isEntityIsNotProformaError = (e: unknown): e is EntityIsNotProformaError =>
e instanceof EntityIsNotProformaError;

View File

@ -1,2 +1,4 @@
export * from "./customer-invoice-id-already-exits-error"; export * from "./customer-invoice-id-already-exits-error";
export * from "./entity-is-not-proforma-error";
export * from "./invalid-proforma-transition-error";
export * from "./proforma-cannot-be-converted-to-invoice-error"; export * from "./proforma-cannot-be-converted-to-invoice-error";

View File

@ -0,0 +1,11 @@
import { DomainError } from "@repo/rdx-ddd";
export class InvalidProformaTransitionError extends DomainError {
constructor(current: string, next: string, id: string) {
super(`Invalid transition for proforma ${id}: ${current}${next}`);
this.name = "InvalidProformaTransitionError";
}
}
export const isInvalidProformaTransitionError = (e: unknown): e is InvalidProformaTransitionError =>
e instanceof InvalidProformaTransitionError;

View File

@ -3,6 +3,7 @@ import { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils"; import { Collection, Result } from "@repo/rdx-utils";
import { CustomerInvoiceListDTO } from "../../infrastructure"; import { CustomerInvoiceListDTO } from "../../infrastructure";
import { CustomerInvoice } from "../aggregates"; import { CustomerInvoice } from "../aggregates";
import { CustomerInvoiceStatus } from "../value-objects";
/** /**
* Interfaz del repositorio para el agregado `CustomerInvoice`. * Interfaz del repositorio para el agregado `CustomerInvoice`.
@ -80,4 +81,21 @@ export interface ICustomerInvoiceRepository {
id: UniqueID, id: UniqueID,
transaction: unknown transaction: unknown
): Promise<Result<boolean, Error>>; ): Promise<Result<boolean, Error>>;
/**
*
* Actualiza el "status" de una proforma
*
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param id - UUID de la factura a eliminar.
* @param newStatus - nuevo estado
* @param transaction - Transacción activa para la operación.
* @returns Result<boolean, Error>
*/
updateProformaStatusByIdInCompany(
companyId: UniqueID,
id: UniqueID,
newStatus: CustomerInvoiceStatus,
transaction: unknown
): Promise<Result<boolean, Error>>;
} }

View File

@ -1 +1,3 @@
export * from "./customer-invoice-number-generator.interface"; export * from "./customer-invoice-number-generator.interface";
export * from "./issue-customer-invoice-domain-service";
export * from "./proforma-customer-invoice-domain-service";

View File

@ -0,0 +1,68 @@
import { UtcDate } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { CustomerInvoice } from "../aggregates";
import { EntityIsNotProformaError, ProformaCannotBeConvertedToInvoiceError } from "../errors";
import {
CustomerInvoiceIsProformaSpecification,
ProformaCanTranstionToIssuedSpecification,
} from "../specs";
import { CustomerInvoiceNumber, CustomerInvoiceStatus } from "../value-objects";
/**
* Servicio de dominio que encapsula la lógica de emisión de factura definitiva desde una proforma.
*/
export class IssueCustomerInvoiceDomainService {
private readonly isProformaSpec = new CustomerInvoiceIsProformaSpecification();
private readonly isApprovedSpec = new ProformaCanTranstionToIssuedSpecification();
public linkWithProforma(
invoice: CustomerInvoice,
proforma: CustomerInvoice
): Result<CustomerInvoice, Error> {}
/**
* Convierte una proforma en factura definitiva.
*
* @param proforma - Entidad CustomerInvoice en estado proforma aprobada.
* @param params.issueNumber - Número de la nueva factura.
* @param params.issueDate - Fecha de emisión.
* @returns Result<CustomerInvoice, Error> - Nueva factura emitida o error de dominio.
*/
public async issueFromProforma(
proforma: CustomerInvoice,
params: {
issueNumber: CustomerInvoiceNumber;
issueDate: UtcDate;
}
): Promise<Result<CustomerInvoice, Error>> {
const { issueDate, issueNumber } = params;
/** 1. Validar que la entidad origen es una proforma */
if (!(await this.isProformaSpec.isSatisfiedBy(proforma))) {
return Result.fail(new EntityIsNotProformaError(proforma.id.toString()));
}
/** 2. Validar que la proforma puede emitirse */
if (!(await this.isApprovedSpec.isSatisfiedBy(proforma))) {
return Result.fail(new ProformaCannotBeConvertedToInvoiceError(proforma.id.toString()));
}
/** 3. Generar la nueva factura definitiva (inmutable) */
const proformaProps = proforma.getProps();
const newInvoiceOrError = CustomerInvoice.create({
...proformaProps,
isProforma: false,
proformaId: Maybe.some(proforma.id),
status: CustomerInvoiceStatus.createIssued(),
invoiceNumber: issueNumber,
invoiceDate: issueDate,
description: proformaProps.description.isNone() ? Maybe.some(".") : proformaProps.description,
});
if (newInvoiceOrError.isFailure) {
return Result.fail(newInvoiceOrError.error);
}
return Result.ok(newInvoiceOrError.data);
}
}

View File

@ -0,0 +1,65 @@
import { Result } from "@repo/rdx-utils";
import { CustomerInvoice } from "../aggregates";
import { EntityIsNotProformaError, InvalidProformaTransitionError } from "../errors";
import { CustomerInvoiceIsProformaSpecification } from "../specs";
import { CustomerInvoiceStatus, INVOICE_STATUS } from "../value-objects";
/**
* Servicio de dominio que encapsula la lógica de emisión de factura definitiva desde una proforma.
*/
export class ProformaCustomerInvoiceDomainService {
/** Aplica la transición si está permitida según INVOICE_TRANSITIONS. */
async transition(
proforma: CustomerInvoice,
nextStatus: string
): Promise<Result<CustomerInvoice, Error>> {
// Validar que la entidad es una proforma
const isProformaSpec = new CustomerInvoiceIsProformaSpecification();
if (!(await isProformaSpec.isSatisfiedBy(proforma))) {
return Result.fail(new EntityIsNotProformaError(proforma.id.toString()));
}
const current = proforma.status.toString();
const allowed = proforma.canTransitionTo(nextStatus);
if (!allowed) {
return Result.fail(
new InvalidProformaTransitionError(current, nextStatus, proforma.id.toString())
);
}
// Validaciones adicionales de dominio, si las hubiera
// (por ejemplo, no aprobar si no hay líneas)
// new ProformaHasLinesSpecification().isSatisfiedBy(proforma)
return CustomerInvoice.create({
...proforma.getProps(),
status: CustomerInvoiceStatus.create(nextStatus).data,
});
}
/** Envía la proforma (draft → sent) */
async send(proforma: CustomerInvoice): Promise<Result<CustomerInvoice, Error>> {
return this.transition(proforma, INVOICE_STATUS.SENT);
}
/** Aprueba la proforma (sent → approved) */
async approve(proforma: CustomerInvoice): Promise<Result<CustomerInvoice, Error>> {
return this.transition(proforma, INVOICE_STATUS.APPROVED);
}
/** Rechaza la proforma (sent → rejected) */
async reject(proforma: CustomerInvoice): Promise<Result<CustomerInvoice, Error>> {
return this.transition(proforma, INVOICE_STATUS.REJECTED);
}
/** Reabre una proforma rechazada (rejected → draft) */
async reopen(proforma: CustomerInvoice): Promise<Result<CustomerInvoice, Error>> {
return this.transition(proforma, INVOICE_STATUS.DRAFT);
}
/** Marca la proforma como emitida (approved → issued) */
async markAsIssued(proforma: CustomerInvoice): Promise<Result<CustomerInvoice, Error>> {
return this.transition(proforma, INVOICE_STATUS.ISSUED);
}
}

View File

@ -0,0 +1,8 @@
import { CompositeSpecification } from "@repo/rdx-ddd";
import { CustomerInvoice } from "../aggregates";
export class CustomerInvoiceIsProformaSpecification extends CompositeSpecification<CustomerInvoice> {
public async isSatisfiedBy(proforma: CustomerInvoice): Promise<boolean> {
return proforma.isProforma;
}
}

View File

@ -1 +1,2 @@
export * from "./customer-invoice-is-proforma.specification";
export * from "./proforma-can-transtion-to-issued.specification"; export * from "./proforma-can-transtion-to-issued.specification";

View File

@ -4,6 +4,6 @@ import { INVOICE_STATUS } from "../value-objects";
export class ProformaCanTranstionToIssuedSpecification extends CompositeSpecification<CustomerInvoice> { export class ProformaCanTranstionToIssuedSpecification extends CompositeSpecification<CustomerInvoice> {
public async isSatisfiedBy(proforma: CustomerInvoice): Promise<boolean> { public async isSatisfiedBy(proforma: CustomerInvoice): Promise<boolean> {
return proforma.isProforma && proforma.canTransitionTo(INVOICE_STATUS.ISSUED); return proforma.canTransitionTo(INVOICE_STATUS.ISSUED);
} }
} }

View File

@ -15,18 +15,20 @@ export enum INVOICE_STATUS {
// status === "issued" <- (si is_proforma === false) => Factura y enviará/enviada a Veri*Factu // status === "issued" <- (si is_proforma === false) => Factura y enviará/enviada a Veri*Factu
ISSUED = "issued", ISSUED = "issued",
} }
export class CustomerInvoiceStatus extends ValueObject<ICustomerInvoiceStatusProps> {
private static readonly ALLOWED_STATUSES = ["draft", "sent", "approved", "rejected", "issued"];
private static readonly FIELD = "invoiceStatus";
private static readonly ERROR_CODE = "INVALID_INVOICE_STATUS";
private static readonly TRANSITIONS: Record<string, string[]> = { const INVOICE_TRANSITIONS: Record<string, string[]> = {
draft: [INVOICE_STATUS.SENT], draft: [INVOICE_STATUS.SENT],
sent: [INVOICE_STATUS.APPROVED, INVOICE_STATUS.REJECTED], sent: [INVOICE_STATUS.APPROVED, INVOICE_STATUS.REJECTED],
approved: [INVOICE_STATUS.ISSUED], approved: [INVOICE_STATUS.ISSUED],
rejected: [INVOICE_STATUS.DRAFT], rejected: [INVOICE_STATUS.DRAFT],
issued: [],
}; };
export class CustomerInvoiceStatus extends ValueObject<ICustomerInvoiceStatusProps> {
private static readonly ALLOWED_STATUSES = ["draft", "sent", "approved", "rejected", "issued"];
private static readonly FIELD = "invoiceStatus";
private static readonly ERROR_CODE = "INVALID_INVOICE_STATUS";
static create(value: string): Result<CustomerInvoiceStatus, Error> { static create(value: string): Result<CustomerInvoiceStatus, Error> {
if (!CustomerInvoiceStatus.ALLOWED_STATUSES.includes(value)) { if (!CustomerInvoiceStatus.ALLOWED_STATUSES.includes(value)) {
const detail = `Estado de la factura no válido: ${value}`; const detail = `Estado de la factura no válido: ${value}`;
@ -89,7 +91,7 @@ export class CustomerInvoiceStatus extends ValueObject<ICustomerInvoiceStatusPro
} }
canTransitionTo(nextStatus: string): boolean { canTransitionTo(nextStatus: string): boolean {
return CustomerInvoiceStatus.TRANSITIONS[this.props.value].includes(nextStatus); return INVOICE_TRANSITIONS[this.props.value].includes(nextStatus);
} }
toString() { toString() {

View File

@ -1,8 +1,7 @@
import { IModuleServer, ModuleParams } from "@erp/core/api"; import { IModuleServer, ModuleParams } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd"; import { UniqueID } from "@repo/rdx-ddd";
import { Transaction } from "sequelize"; import { Transaction } from "sequelize";
import { customerInvoicesRouter, models } from "./infrastructure"; import { buildCustomerInvoiceDependencies, customerInvoicesRouter, models } from "./infrastructure";
import { buildCustomerInvoiceDependencies } from "./infrastructure/dependencies";
export const customerInvoicesAPIModule: IModuleServer = { export const customerInvoicesAPIModule: IModuleServer = {
name: "customer-invoices", name: "customer-invoices",
@ -17,7 +16,7 @@ export const customerInvoicesAPIModule: IModuleServer = {
}, },
async registerDependencies(params) { async registerDependencies(params) {
const { logger, listServices } = params; /* = ModuleParams & { const { logger } = params; /* = ModuleParams & {
getService: (name: string) => any; getService: (name: string) => any;
};*/ };*/
@ -25,9 +24,6 @@ export const customerInvoicesAPIModule: IModuleServer = {
label: this.name, label: this.name,
}); });
logger.info(listServices());
//getService()
const deps = buildCustomerInvoiceDependencies(params); const deps = buildCustomerInvoiceDependencies(params);
return { return {
@ -38,9 +34,9 @@ export const customerInvoicesAPIModule: IModuleServer = {
invoiceId: UniqueID, invoiceId: UniqueID,
transaction?: Transaction transaction?: Transaction
) => { ) => {
const { service } = deps; /*const { service } = deps;
return service.getInvoiceByIdInCompany(companyId, invoiceId, transaction); return service.getInvoiceByIdInCompany(companyId, invoiceId, transaction);*/
}, },
}, },
}; };

View File

@ -0,0 +1,169 @@
// modules/invoice/infrastructure/invoice-dependencies.factory.ts
import { JsonTaxCatalogProvider, SpainTaxCatalogProvider } from "@erp/core";
import type { IMapperRegistry, IPresenterRegistry, ModuleParams } from "@erp/core/api";
import {
InMemoryMapperRegistry,
InMemoryPresenterRegistry,
SequelizeTransactionManager,
} from "@erp/core/api";
import {
CreateCustomerInvoiceUseCase,
CustomerInvoiceApplicationService,
CustomerInvoiceFullPresenter,
CustomerInvoiceItemsFullPresenter,
CustomerInvoiceItemsReportPersenter,
CustomerInvoiceReportHTMLPresenter,
CustomerInvoiceReportPDFPresenter,
CustomerInvoiceReportPresenter,
GetCustomerInvoiceUseCase,
IssueCustomerInvoiceUseCase,
ListCustomerInvoicesPresenter,
ListCustomerInvoicesUseCase,
RecipientInvoiceFullPresenter,
ReportCustomerInvoiceUseCase,
UpdateCustomerInvoiceUseCase,
} from "../application";
import { CustomerInvoiceDomainMapper, CustomerInvoiceListMapper } from "./mappers";
import { CustomerInvoiceRepository } from "./sequelize";
import { SequelizeInvoiceNumberGenerator } from "./services";
export type CustomerInvoiceDeps = {
transactionManager: SequelizeTransactionManager;
mapperRegistry: IMapperRegistry;
presenterRegistry: IPresenterRegistry;
repo: CustomerInvoiceRepository;
service: CustomerInvoiceApplicationService;
catalogs: {
taxes: JsonTaxCatalogProvider;
};
build: {
list: () => ListCustomerInvoicesUseCase;
get: () => GetCustomerInvoiceUseCase;
create: () => CreateCustomerInvoiceUseCase;
update: () => UpdateCustomerInvoiceUseCase;
//delete: () => DeleteCustomerInvoiceUseCase;
report: () => ReportCustomerInvoiceUseCase;
issue: () => IssueCustomerInvoiceUseCase;
};
getService: (name: string) => any;
listServices: () => string[];
};
export function buildCustomerInvoiceDependencies(params: ModuleParams): CustomerInvoiceDeps {
const { database, listServices, getService } = params;
const transactionManager = new SequelizeTransactionManager(database);
const catalogs = { taxes: SpainTaxCatalogProvider() };
// Mapper Registry
const mapperRegistry = new InMemoryMapperRegistry();
mapperRegistry
.registerDomainMapper(
{ resource: "customer-invoice" },
new CustomerInvoiceDomainMapper({ taxCatalog: catalogs.taxes })
)
.registerQueryMappers([
{
key: { resource: "customer-invoice", query: "LIST" },
mapper: new CustomerInvoiceListMapper(),
},
]);
// Repository & Services
const repo = new CustomerInvoiceRepository({ mapperRegistry, database });
const numberGenerator = new SequelizeInvoiceNumberGenerator();
const service = new CustomerInvoiceApplicationService(repo, numberGenerator);
// Presenter Registry
const presenterRegistry = new InMemoryPresenterRegistry();
presenterRegistry.registerPresenters([
{
key: {
resource: "customer-invoice-items",
projection: "FULL",
},
presenter: new CustomerInvoiceItemsFullPresenter(presenterRegistry),
},
{
key: {
resource: "recipient-invoice",
projection: "FULL",
},
presenter: new RecipientInvoiceFullPresenter(presenterRegistry),
},
{
key: {
resource: "customer-invoice",
projection: "FULL",
},
presenter: new CustomerInvoiceFullPresenter(presenterRegistry),
},
{
key: {
resource: "customer-invoice",
projection: "LIST",
},
presenter: new ListCustomerInvoicesPresenter(presenterRegistry),
},
{
key: {
resource: "customer-invoice",
projection: "REPORT",
format: "JSON",
},
presenter: new CustomerInvoiceReportPresenter(presenterRegistry),
},
{
key: {
resource: "customer-invoice-items",
projection: "REPORT",
format: "JSON",
},
presenter: new CustomerInvoiceItemsReportPersenter(presenterRegistry),
},
{
key: {
resource: "customer-invoice",
projection: "REPORT",
format: "HTML",
},
presenter: new CustomerInvoiceReportHTMLPresenter(presenterRegistry),
},
{
key: {
resource: "customer-invoice",
projection: "REPORT",
format: "PDF",
},
presenter: new CustomerInvoiceReportPDFPresenter(presenterRegistry),
},
]);
return {
transactionManager,
repo,
mapperRegistry,
presenterRegistry,
service,
catalogs,
build: {
list: () => new ListCustomerInvoicesUseCase(service, transactionManager, presenterRegistry),
get: () => new GetCustomerInvoiceUseCase(service, transactionManager, presenterRegistry),
create: () =>
new CreateCustomerInvoiceUseCase(
service,
transactionManager,
presenterRegistry,
catalogs.taxes
),
update: () =>
new UpdateCustomerInvoiceUseCase(service, transactionManager, presenterRegistry),
// delete: () => new DeleteCustomerInvoiceUseCase(service, transactionManager),
report: () =>
new ReportCustomerInvoiceUseCase(service, transactionManager, presenterRegistry),
issue: () => new IssueCustomerInvoiceUseCase(service, transactionManager),
},
listServices,
getService,
};
}

View File

@ -8,6 +8,7 @@ import {
SequelizeTransactionManager, SequelizeTransactionManager,
} from "@erp/core/api"; } from "@erp/core/api";
import { import {
ChangeStatusCustomerInvoiceUseCase,
CreateCustomerInvoiceUseCase, CreateCustomerInvoiceUseCase,
CustomerInvoiceApplicationService, CustomerInvoiceApplicationService,
CustomerInvoiceFullPresenter, CustomerInvoiceFullPresenter,
@ -33,11 +34,11 @@ export type CustomerInvoiceDeps = {
mapperRegistry: IMapperRegistry; mapperRegistry: IMapperRegistry;
presenterRegistry: IPresenterRegistry; presenterRegistry: IPresenterRegistry;
repo: CustomerInvoiceRepository; repo: CustomerInvoiceRepository;
service: CustomerInvoiceApplicationService; appService: CustomerInvoiceApplicationService;
catalogs: { catalogs: {
taxes: JsonTaxCatalogProvider; taxes: JsonTaxCatalogProvider;
}; };
build: { useCases: {
list: () => ListCustomerInvoicesUseCase; list: () => ListCustomerInvoicesUseCase;
get: () => GetCustomerInvoiceUseCase; get: () => GetCustomerInvoiceUseCase;
create: () => CreateCustomerInvoiceUseCase; create: () => CreateCustomerInvoiceUseCase;
@ -45,17 +46,19 @@ export type CustomerInvoiceDeps = {
//delete: () => DeleteCustomerInvoiceUseCase; //delete: () => DeleteCustomerInvoiceUseCase;
report: () => ReportCustomerInvoiceUseCase; report: () => ReportCustomerInvoiceUseCase;
issue: () => IssueCustomerInvoiceUseCase; issue: () => IssueCustomerInvoiceUseCase;
changeStatus: () => ChangeStatusCustomerInvoiceUseCase;
}; };
getService: (name: string) => any;
listServices: () => string[];
}; };
export function buildCustomerInvoiceDependencies(params: ModuleParams): CustomerInvoiceDeps { export function buildCustomerInvoiceDependencies(params: ModuleParams): CustomerInvoiceDeps {
const { database, listServices, getService } = params; const { database } = params;
const transactionManager = new SequelizeTransactionManager(database);
/** Dominio */
const catalogs = { taxes: SpainTaxCatalogProvider() }; const catalogs = { taxes: SpainTaxCatalogProvider() };
// Mapper Registry /** Infraestructura */
const transactionManager = new SequelizeTransactionManager(database);
const mapperRegistry = new InMemoryMapperRegistry(); const mapperRegistry = new InMemoryMapperRegistry();
mapperRegistry mapperRegistry
.registerDomainMapper( .registerDomainMapper(
@ -70,100 +73,74 @@ export function buildCustomerInvoiceDependencies(params: ModuleParams): Customer
]); ]);
// Repository & Services // Repository & Services
const repo = new CustomerInvoiceRepository({ mapperRegistry, database }); const repository = new CustomerInvoiceRepository({ mapperRegistry, database });
const numberGenerator = new SequelizeInvoiceNumberGenerator(); const numberGenerator = new SequelizeInvoiceNumberGenerator();
const service = new CustomerInvoiceApplicationService(repo, numberGenerator);
/** Aplicación */
const appService = new CustomerInvoiceApplicationService(repository, numberGenerator);
// Presenter Registry // Presenter Registry
const presenterRegistry = new InMemoryPresenterRegistry(); const presenterRegistry = new InMemoryPresenterRegistry();
presenterRegistry.registerPresenters([ presenterRegistry.registerPresenters([
{ {
key: { key: { resource: "customer-invoice-items", projection: "FULL" },
resource: "customer-invoice-items",
projection: "FULL",
},
presenter: new CustomerInvoiceItemsFullPresenter(presenterRegistry), presenter: new CustomerInvoiceItemsFullPresenter(presenterRegistry),
}, },
{ {
key: { key: { resource: "recipient-invoice", projection: "FULL" },
resource: "recipient-invoice",
projection: "FULL",
},
presenter: new RecipientInvoiceFullPresenter(presenterRegistry), presenter: new RecipientInvoiceFullPresenter(presenterRegistry),
}, },
{ {
key: { key: { resource: "customer-invoice", projection: "FULL" },
resource: "customer-invoice",
projection: "FULL",
},
presenter: new CustomerInvoiceFullPresenter(presenterRegistry), presenter: new CustomerInvoiceFullPresenter(presenterRegistry),
}, },
{ {
key: { key: { resource: "customer-invoice", projection: "LIST" },
resource: "customer-invoice",
projection: "LIST",
},
presenter: new ListCustomerInvoicesPresenter(presenterRegistry), presenter: new ListCustomerInvoicesPresenter(presenterRegistry),
}, },
{ {
key: { key: { resource: "customer-invoice", projection: "REPORT", format: "JSON" },
resource: "customer-invoice",
projection: "REPORT",
format: "JSON",
},
presenter: new CustomerInvoiceReportPresenter(presenterRegistry), presenter: new CustomerInvoiceReportPresenter(presenterRegistry),
}, },
{ {
key: { key: { resource: "customer-invoice-items", projection: "REPORT", format: "JSON" },
resource: "customer-invoice-items",
projection: "REPORT",
format: "JSON",
},
presenter: new CustomerInvoiceItemsReportPersenter(presenterRegistry), presenter: new CustomerInvoiceItemsReportPersenter(presenterRegistry),
}, },
{ {
key: { key: { resource: "customer-invoice", projection: "REPORT", format: "HTML" },
resource: "customer-invoice",
projection: "REPORT",
format: "HTML",
},
presenter: new CustomerInvoiceReportHTMLPresenter(presenterRegistry), presenter: new CustomerInvoiceReportHTMLPresenter(presenterRegistry),
}, },
{ {
key: { key: { resource: "customer-invoice", projection: "REPORT", format: "PDF" },
resource: "customer-invoice",
projection: "REPORT",
format: "PDF",
},
presenter: new CustomerInvoiceReportPDFPresenter(presenterRegistry), presenter: new CustomerInvoiceReportPDFPresenter(presenterRegistry),
}, },
]); ]);
return { const useCases = {
transactionManager, list: () => new ListCustomerInvoicesUseCase(appService, transactionManager, presenterRegistry),
repo, get: () => new GetCustomerInvoiceUseCase(appService, transactionManager, presenterRegistry),
mapperRegistry,
presenterRegistry,
service,
catalogs,
build: {
list: () => new ListCustomerInvoicesUseCase(service, transactionManager, presenterRegistry),
get: () => new GetCustomerInvoiceUseCase(service, transactionManager, presenterRegistry),
create: () => create: () =>
new CreateCustomerInvoiceUseCase( new CreateCustomerInvoiceUseCase(
service, appService,
transactionManager, transactionManager,
presenterRegistry, presenterRegistry,
catalogs.taxes catalogs.taxes
), ),
update: () => update: () =>
new UpdateCustomerInvoiceUseCase(service, transactionManager, presenterRegistry), new UpdateCustomerInvoiceUseCase(appService, transactionManager, presenterRegistry),
// delete: () => new DeleteCustomerInvoiceUseCase(service, transactionManager),
report: () => report: () =>
new ReportCustomerInvoiceUseCase(service, transactionManager, presenterRegistry), new ReportCustomerInvoiceUseCase(appService, transactionManager, presenterRegistry),
issue: () => new IssueCustomerInvoiceUseCase(service, transactionManager), issue: () => new IssueCustomerInvoiceUseCase(appService, transactionManager, presenterRegistry),
}, changeStatus: () => new ChangeStatusCustomerInvoiceUseCase(appService, transactionManager),
listServices, };
getService,
return {
transactionManager,
repo: repository,
mapperRegistry,
presenterRegistry,
appService,
catalogs,
useCases,
}; };
} }

View File

@ -0,0 +1,34 @@
import { authGuard, ExpressController, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import { ChangeStatusCustomerInvoiceByIdRequestDTO } from "@erp/customer-invoices/common";
import { ChangeStatusCustomerInvoiceUseCase } from "../../../application";
import { customerInvoicesApiErrorMapper } from "../customer-invoices-api-error-mapper";
export class ChangeStatusCustomerInvoiceController extends ExpressController {
public constructor(private readonly useCase: ChangeStatusCustomerInvoiceUseCase) {
super();
this.errorMapper = customerInvoicesApiErrorMapper;
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
}
async executeImpl(): Promise<any> {
const companyId = this.getTenantId(); // garantizado por tenantGuard
if (!companyId) {
return this.forbiddenError("Tenant ID not found");
}
const { proforma_id } = this.req.params;
if (!proforma_id) {
return this.invalidInputError("Proforma ID missing");
}
const dto = this.req.body as ChangeStatusCustomerInvoiceByIdRequestDTO;
const result = await this.useCase.execute({ proforma_id, dto, companyId });
return result.match(
(data) => this.ok(data),
(err) => this.handleError(err)
);
}
}

View File

@ -1,7 +1,8 @@
export * from "./change-status-customer-invoice.controller";
export * from "./create-customer-invoice.controller"; export * from "./create-customer-invoice.controller";
//export * from "./delete-customer-invoice.controller"; //export * from "./delete-customer-invoice.controller";
export * from "./get-customer-invoice.controller"; export * from "./get-customer-invoice.controller";
//export * from "./issue-customer-invoice.controller"; export * from "./issue-customer-invoice.controller";
export * from "./list-customer-invoices.controller"; export * from "./list-customer-invoices.controller";
export * from "./report-customer-invoice.controller"; export * from "./report-customer-invoice.controller";
export * from "./update-customer-invoice.controller"; export * from "./update-customer-invoice.controller";

View File

@ -9,7 +9,9 @@ import {
} from "@erp/core/api"; } from "@erp/core/api";
import { import {
CustomerInvoiceIdAlreadyExistsError, CustomerInvoiceIdAlreadyExistsError,
EntityIsNotProformaError,
isCustomerInvoiceIdAlreadyExistsError, isCustomerInvoiceIdAlreadyExistsError,
isEntityIsNotProformaError,
isProformaCannotBeConvertedToInvoiceError, isProformaCannotBeConvertedToInvoiceError,
ProformaCannotBeConvertedToInvoiceError, ProformaCannotBeConvertedToInvoiceError,
} from "../../domain"; } from "../../domain";
@ -25,6 +27,15 @@ const invoiceDuplicateRule: ErrorToApiRule = {
), ),
}; };
const entityIsNotProformaError: ErrorToApiRule = {
priority: 120,
matches: (e) => isEntityIsNotProformaError(e),
build: (e) =>
new ValidationApiError(
(e as EntityIsNotProformaError).message || "Entity with the provided id is not proforma"
),
};
const proformaConversionRule: ErrorToApiRule = { const proformaConversionRule: ErrorToApiRule = {
priority: 120, priority: 120,
matches: (e) => isProformaCannotBeConvertedToInvoiceError(e), matches: (e) => isProformaCannotBeConvertedToInvoiceError(e),
@ -38,4 +49,5 @@ const proformaConversionRule: ErrorToApiRule = {
// Cómo aplicarla: crea una nueva instancia del mapper con la regla extra // Cómo aplicarla: crea una nueva instancia del mapper con la regla extra
export const customerInvoicesApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default() export const customerInvoicesApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default()
.register(invoiceDuplicateRule) .register(invoiceDuplicateRule)
.register(entityIsNotProformaError)
.register(proformaConversionRule); .register(proformaConversionRule);

View File

@ -4,6 +4,8 @@ import { ILogger } from "@repo/rdx-logger";
import { Application, NextFunction, Request, Response, Router } from "express"; import { Application, NextFunction, Request, Response, Router } from "express";
import { Sequelize } from "sequelize"; import { Sequelize } from "sequelize";
import { import {
ChangeStatusCustomerInvoiceByIdParamsRequestSchema,
ChangeStatusCustomerInvoiceByIdRequestSchema,
CreateCustomerInvoiceRequestSchema, CreateCustomerInvoiceRequestSchema,
CustomerInvoiceListRequestSchema, CustomerInvoiceListRequestSchema,
GetCustomerInvoiceByIdRequestSchema, GetCustomerInvoiceByIdRequestSchema,
@ -13,6 +15,7 @@ import {
} from "../../../common/dto"; } from "../../../common/dto";
import { buildCustomerInvoiceDependencies } from "../dependencies"; import { buildCustomerInvoiceDependencies } from "../dependencies";
import { import {
ChangeStatusCustomerInvoiceController,
CreateCustomerInvoiceController, CreateCustomerInvoiceController,
GetCustomerInvoiceController, GetCustomerInvoiceController,
ListCustomerInvoicesController, ListCustomerInvoicesController,
@ -55,7 +58,7 @@ export const customerInvoicesRouter = (params: ModuleParams) => {
//checkTabContext, //checkTabContext,
validateRequest(CustomerInvoiceListRequestSchema, "params"), validateRequest(CustomerInvoiceListRequestSchema, "params"),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const useCase = deps.build.list(); const useCase = deps.useCases.list();
const controller = new ListCustomerInvoicesController(useCase /*, deps.presenters.list */); const controller = new ListCustomerInvoicesController(useCase /*, deps.presenters.list */);
return controller.execute(req, res, next); return controller.execute(req, res, next);
} }
@ -66,7 +69,7 @@ export const customerInvoicesRouter = (params: ModuleParams) => {
//checkTabContext, //checkTabContext,
validateRequest(GetCustomerInvoiceByIdRequestSchema, "params"), validateRequest(GetCustomerInvoiceByIdRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
const useCase = deps.build.get(); const useCase = deps.useCases.get();
const controller = new GetCustomerInvoiceController(useCase); const controller = new GetCustomerInvoiceController(useCase);
return controller.execute(req, res, next); return controller.execute(req, res, next);
} }
@ -78,7 +81,7 @@ export const customerInvoicesRouter = (params: ModuleParams) => {
validateRequest(CreateCustomerInvoiceRequestSchema, "body"), validateRequest(CreateCustomerInvoiceRequestSchema, "body"),
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
const useCase = deps.build.create(); const useCase = deps.useCases.create();
const controller = new CreateCustomerInvoiceController(useCase); const controller = new CreateCustomerInvoiceController(useCase);
return controller.execute(req, res, next); return controller.execute(req, res, next);
} }
@ -91,7 +94,7 @@ export const customerInvoicesRouter = (params: ModuleParams) => {
validateRequest(UpdateCustomerInvoiceByIdParamsRequestSchema, "params"), validateRequest(UpdateCustomerInvoiceByIdParamsRequestSchema, "params"),
validateRequest(UpdateCustomerInvoiceByIdRequestSchema, "body"), validateRequest(UpdateCustomerInvoiceByIdRequestSchema, "body"),
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
const useCase = deps.build.update(); const useCase = deps.useCases.update();
const controller = new UpdateCustomerInvoiceController(useCase); const controller = new UpdateCustomerInvoiceController(useCase);
return controller.execute(req, res, next); return controller.execute(req, res, next);
} }
@ -103,7 +106,7 @@ export const customerInvoicesRouter = (params: ModuleParams) => {
validateRequest(DeleteCustomerInvoiceByIdRequestSchema, "params"), validateRequest(DeleteCustomerInvoiceByIdRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
const useCase = deps.build.delete(); const useCase = deps.useCases.delete();
const controller = new DeleteCustomerInvoiceController(useCase); const controller = new DeleteCustomerInvoiceController(useCase);
return controller.execute(req, res, next); return controller.execute(req, res, next);
} }
@ -114,12 +117,26 @@ export const customerInvoicesRouter = (params: ModuleParams) => {
//checkTabContext, //checkTabContext,
validateRequest(ReportCustomerInvoiceByIdRequestSchema, "params"), validateRequest(ReportCustomerInvoiceByIdRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
const useCase = deps.build.report(); const useCase = deps.useCases.report();
const controller = new ReportCustomerInvoiceController(useCase); const controller = new ReportCustomerInvoiceController(useCase);
return controller.execute(req, res, next); return controller.execute(req, res, next);
} }
); );
router.patch(
"/:proforma_id/status",
//checkTabContext,
validateRequest(ChangeStatusCustomerInvoiceByIdParamsRequestSchema, "params"),
validateRequest(ChangeStatusCustomerInvoiceByIdRequestSchema, "body"),
(req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.changeStatus();
const controller = new ChangeStatusCustomerInvoiceController(useCase);
return controller.execute(req, res, next);
}
);
router.put( router.put(
"/:proforma_id/issue", "/:proforma_id/issue",
//checkTabContext, //checkTabContext,
@ -128,7 +145,7 @@ export const customerInvoicesRouter = (params: ModuleParams) => {
validateRequest(XXX, "body"),*/ validateRequest(XXX, "body"),*/
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
const useCase = deps.build.issue(); const useCase = deps.useCases.issue();
const controller = new IssueCustomerInvoiceController(useCase); const controller = new IssueCustomerInvoiceController(useCase);
return controller.execute(req, res, next); return controller.execute(req, res, next);
} }

View File

@ -1,3 +1,4 @@
export * from "./dependencies";
export * from "./express"; export * from "./express";
export * from "./mappers"; export * from "./mappers";
export * from "./sequelize"; export * from "./sequelize";

View File

@ -313,6 +313,7 @@ export class CustomerInvoiceDomainMapper
parent: source, parent: source,
...params, ...params,
}); });
if (itemsResult.isFailure) { if (itemsResult.isFailure) {
errors.push({ errors.push({
path: "items", path: "items",
@ -320,14 +321,13 @@ export class CustomerInvoiceDomainMapper
}); });
} }
const items = itemsResult.data;
// 2) Taxes // 2) Taxes
const taxesResult = this._taxesMapper.mapToPersistenceArray(new Collection(source.getTaxes()), { const taxesResult = this._taxesMapper.mapToPersistenceArray(new Collection(source.getTaxes()), {
errors, errors,
parent: source, parent: source,
...params, ...params,
}); });
if (taxesResult.isFailure) { if (taxesResult.isFailure) {
errors.push({ errors.push({
path: "taxes", path: "taxes",
@ -335,25 +335,25 @@ export class CustomerInvoiceDomainMapper
}); });
} }
const taxes = taxesResult.data; // 3) Cliente
// 3) Calcular totales
const allAmounts = source.getAllAmounts();
// 4) Cliente
const recipient = this._recipientMapper.mapToPersistence(source.recipient, { const recipient = this._recipientMapper.mapToPersistence(source.recipient, {
errors, errors,
parent: source, parent: source,
...params, ...params,
}); });
// 7) Si hubo errores de mapeo, devolvemos colección de validación // 4) Si hubo errores de mapeo, devolvemos colección de validación
if (errors.length > 0) { if (errors.length > 0) {
return Result.fail( return Result.fail(
new ValidationErrorCollection("Customer invoice mapping to persistence failed", errors) new ValidationErrorCollection("Customer invoice mapping to persistence failed", errors)
); );
} }
const items = itemsResult.data;
const taxes = taxesResult.data;
const allAmounts = source.getAllAmounts(); // Da los totales ya calculados
const invoiceValues: Partial<CustomerInvoiceCreationAttributes> = { const invoiceValues: Partial<CustomerInvoiceCreationAttributes> = {
// Identificación // Identificación
id: source.id.toPrimitive(), id: source.id.toPrimitive(),
@ -381,8 +381,8 @@ export class CustomerInvoiceDomainMapper
discount_percentage_value: source.discountPercentage.toPrimitive().value, discount_percentage_value: source.discountPercentage.toPrimitive().value,
discount_percentage_scale: source.discountPercentage.toPrimitive().scale, discount_percentage_scale: source.discountPercentage.toPrimitive().scale,
discount_amount_value: allAmounts.discountAmount.value, discount_amount_value: allAmounts.headerDiscountAmount.value,
discount_amount_scale: allAmounts.discountAmount.scale, discount_amount_scale: allAmounts.headerDiscountAmount.scale,
taxable_amount_value: allAmounts.taxableAmount.value, taxable_amount_value: allAmounts.taxableAmount.value,
taxable_amount_scale: allAmounts.taxableAmount.scale, taxable_amount_scale: allAmounts.taxableAmount.scale,

View File

@ -8,7 +8,7 @@ import { Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/serve
import { UniqueID } from "@repo/rdx-ddd"; import { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils"; import { Collection, Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize"; import { Transaction } from "sequelize";
import { CustomerInvoice, ICustomerInvoiceRepository } from "../../domain"; import { CustomerInvoice, CustomerInvoiceStatus, ICustomerInvoiceRepository } from "../../domain";
import { import {
CustomerInvoiceListDTO, CustomerInvoiceListDTO,
ICustomerInvoiceDomainMapper, ICustomerInvoiceDomainMapper,
@ -102,7 +102,13 @@ export class CustomerInvoiceRepository
}); });
const dto = mapper.mapToPersistence(invoice); const dto = mapper.mapToPersistence(invoice);
if (dto.isFailure) {
return Result.fail(dto.error);
}
const { id, ...updatePayload } = dto.data; const { id, ...updatePayload } = dto.data;
console.log(id);
const [affected] = await CustomerInvoiceModel.update(updatePayload, { const [affected] = await CustomerInvoiceModel.update(updatePayload, {
where: { id /*, version */ }, where: { id /*, version */ },
//fields: Object.keys(updatePayload), //fields: Object.keys(updatePayload),
@ -110,6 +116,8 @@ export class CustomerInvoiceRepository
individualHooks: true, individualHooks: true,
}); });
console.log(affected);
if (affected === 0) { if (affected === 0) {
return Result.fail( return Result.fail(
new InfrastructureRepositoryError( new InfrastructureRepositoryError(
@ -332,4 +340,49 @@ export class CustomerInvoiceRepository
return Result.fail(translateSequelizeError(err)); return Result.fail(translateSequelizeError(err));
} }
} }
/**
*
* Actualiza el "status" de una proforma
*
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param id - UUID de la factura a eliminar.
* @param newStatus - nuevo estado
* @param transaction - Transacción activa para la operación.
* @returns Result<boolean, Error>
*/
async updateProformaStatusByIdInCompany(
companyId: UniqueID,
id: UniqueID,
newStatus: CustomerInvoiceStatus,
transaction: Transaction
): Promise<Result<boolean, Error>> {
try {
const [affected] = await CustomerInvoiceModel.update(
{
status: newStatus.toPrimitive(),
},
{
where: { id: id.toPrimitive(), company_id: companyId.toPrimitive() },
fields: ["status"],
transaction,
individualHooks: true,
}
);
console.log(affected);
if (affected === 0) {
return Result.fail(
new InfrastructureRepositoryError(
"Concurrency conflict or not found update customer invoice"
)
);
}
return Result.ok(true);
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
} }

View File

@ -0,0 +1,13 @@
import { z } from "zod/v4";
export const ChangeStatusCustomerInvoiceByIdParamsRequestSchema = z.object({
proforma_id: z.string(),
});
export const ChangeStatusCustomerInvoiceByIdRequestSchema = z.object({
new_status: z.string(),
});
export type ChangeStatusCustomerInvoiceByIdRequestDTO = Partial<
z.infer<typeof ChangeStatusCustomerInvoiceByIdRequestSchema>
>;

View File

@ -1,3 +1,4 @@
export * from "./change-status-customer-invoice-by-id.request.dto";
export * from "./create-customer-invoice.request.dto"; export * from "./create-customer-invoice.request.dto";
export * from "./customer-invoices-list.request.dto"; export * from "./customer-invoices-list.request.dto";
export * from "./delete-customer-invoice-by-id.request.dto"; export * from "./delete-customer-invoice-by-id.request.dto";

View File

@ -5,6 +5,7 @@ export const GetCustomerInvoiceByIdResponseSchema = z.object({
id: z.uuid(), id: z.uuid(),
company_id: z.uuid(), company_id: z.uuid(),
is_proforma: z.string(),
invoice_number: z.string(), invoice_number: z.string(),
status: z.string(), status: z.string(),
series: z.string(), series: z.string(),

View File

@ -40,6 +40,7 @@
"@repo/shadcn-ui": "workspace:*", "@repo/shadcn-ui": "workspace:*",
"@tanstack/react-query": "^5.90.6", "@tanstack/react-query": "^5.90.6",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"express": "^4.18.2",
"i18next": "^25.6.0", "i18next": "^25.6.0",
"lucide-react": "^0.503.0", "lucide-react": "^0.503.0",
"react-data-table-component": "^7.7.0", "react-data-table-component": "^7.7.0",

View File

@ -1,4 +1,4 @@
import { RequestWithAuth, enforceTenant, enforceUser, mockUser } from "@erp/auth/api"; import { enforceTenant, enforceUser, mockUser, RequestWithAuth } from "@erp/auth/api";
import { ModuleParams, validateRequest } from "@erp/core/api"; import { ModuleParams, validateRequest } from "@erp/core/api";
import { ILogger } from "@repo/rdx-logger"; import { ILogger } from "@repo/rdx-logger";
import { Application, NextFunction, Request, Response, Router } from "express"; import { Application, NextFunction, Request, Response, Router } from "express";

View File

@ -586,6 +586,9 @@ importers:
'@tanstack/react-table': '@tanstack/react-table':
specifier: ^8.21.3 specifier: ^8.21.3
version: 8.21.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 8.21.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
express:
specifier: ^4.18.2
version: 4.21.2
i18next: i18next:
specifier: ^25.6.0 specifier: ^25.6.0
version: 25.6.0(typescript@5.9.3) version: 25.6.0(typescript@5.9.3)