diff --git a/apps/server/package.json b/apps/server/package.json index 33cb9b99..8132824f 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -5,7 +5,7 @@ "scripts": { "build": "tsup src/index.ts --config tsup.config.ts", "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", "typecheck": "tsc --noEmit", "lint": "biome lint --fix", diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 54f91854..fe01b53a 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,4 +1,3 @@ -import { globalErrorHandler } from "@erp/core/api"; import cors, { CorsOptions } from "cors"; import express, { Application } from "express"; import helmet from "helmet"; @@ -90,7 +89,7 @@ export function createApp(): Application { // Gestión global de errores. // Siempre al final de la cadena de middlewares // y después de las rutas. - app.use(globalErrorHandler); + //app.use(globalErrorHandler); return app; } diff --git a/modules/customer-invoices/src/api/application/index.ts b/modules/customer-invoices/src/api/application/index.ts index 381b2a83..76373b44 100644 --- a/modules/customer-invoices/src/api/application/index.ts +++ b/modules/customer-invoices/src/api/application/index.ts @@ -1,3 +1,3 @@ -export * from "./customer-invoice-application.service"; export * from "./presenters"; +export * from "./services"; export * from "./use-cases"; diff --git a/modules/customer-invoices/src/api/application/presenters/domain/customer-invoice.full.presenter.ts b/modules/customer-invoices/src/api/application/presenters/domain/customer-invoice.full.presenter.ts index 23ab3efc..2ccfab59 100644 --- a/modules/customer-invoices/src/api/application/presenters/domain/customer-invoice.full.presenter.ts +++ b/modules/customer-invoices/src/api/application/presenters/domain/customer-invoice.full.presenter.ts @@ -47,6 +47,7 @@ export class CustomerInvoiceFullPresenter extends Presenter< id: invoice.id.toString(), company_id: invoice.companyId.toString(), + is_proforma: invoice.isProforma ? "true" : "false", invoice_number: invoice.invoiceNumber.toString(), status: invoice.status.toPrimitive(), series: toEmptyString(invoice.series, (value) => value.toString()), diff --git a/modules/customer-invoices/src/api/application/customer-invoice-application.service.ts b/modules/customer-invoices/src/api/application/services/customer-invoice-application.service.ts similarity index 88% rename from modules/customer-invoices/src/api/application/customer-invoice-application.service.ts rename to modules/customer-invoices/src/api/application/services/customer-invoice-application.service.ts index e8cc3a26..9832e5e7 100644 --- a/modules/customer-invoices/src/api/application/customer-invoice-application.service.ts +++ b/modules/customer-invoices/src/api/application/services/customer-invoice-application.service.ts @@ -5,15 +5,16 @@ import { Transaction } from "sequelize"; import { CustomerInvoiceNumber, CustomerInvoiceSerie, + CustomerInvoiceStatus, ICustomerInvoiceNumberGenerator, -} from "../domain"; +} from "../../domain"; import { CustomerInvoice, CustomerInvoicePatchProps, CustomerInvoiceProps, -} from "../domain/aggregates"; -import { ICustomerInvoiceRepository } from "../domain/repositories"; -import { CustomerInvoiceListDTO } from "../infrastructure"; +} from "../../domain/aggregates"; +import { ICustomerInvoiceRepository } from "../../domain/repositories"; +import { CustomerInvoiceListDTO } from "../../infrastructure"; export class CustomerInvoiceApplicationService { constructor( @@ -67,28 +68,6 @@ export class CustomerInvoiceApplicationService { 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 - El agregado construido o un error si falla la creación. - */ - buildIssueInvoiceInCompany( - companyId: UniqueID, - proforma: CustomerInvoice, - patchProps: CustomerInvoicePatchProps - ): Result { - const proformaProps = proforma.getIssuedInvoiceProps(); - return CustomerInvoice.create({ - ...proformaProps, - ...patchProps, - companyId, - }); - } - /** * Guarda una nueva factura y devuelve la factura guardada. * @@ -226,4 +205,28 @@ export class CustomerInvoiceApplicationService { ): Promise> { 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 + */ + async updateProformaStatusByIdInCompany( + companyId: UniqueID, + proformaId: UniqueID, + newStatus: CustomerInvoiceStatus, + transaction?: Transaction + ): Promise> { + return this.repository.updateProformaStatusByIdInCompany( + companyId, + proformaId, + newStatus, + transaction + ); + } } diff --git a/modules/customer-invoices/src/api/application/services/index.ts b/modules/customer-invoices/src/api/application/services/index.ts new file mode 100644 index 00000000..92829664 --- /dev/null +++ b/modules/customer-invoices/src/api/application/services/index.ts @@ -0,0 +1 @@ +export * from "./customer-invoice-application.service"; diff --git a/modules/customer-invoices/src/api/application/use-cases/change-status-customer-invoice.use-case.ts b/modules/customer-invoices/src/api/application/use-cases/change-status-customer-invoice.use-case.ts new file mode 100644 index 00000000..98ec6af3 --- /dev/null +++ b/modules/customer-invoices/src/api/application/use-cases/change-status-customer-invoice.use-case.ts @@ -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); + } + }); + } +} diff --git a/modules/customer-invoices/src/api/application/use-cases/create/create-customer-invoice.use-case.ts b/modules/customer-invoices/src/api/application/use-cases/create/create-customer-invoice.use-case.ts index e67b2206..b5d6c125 100644 --- a/modules/customer-invoices/src/api/application/use-cases/create/create-customer-invoice.use-case.ts +++ b/modules/customer-invoices/src/api/application/use-cases/create/create-customer-invoice.use-case.ts @@ -4,8 +4,8 @@ import { UniqueID } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; import { Transaction } from "sequelize"; import { CreateCustomerInvoiceRequestDTO } from "../../../../common/dto"; -import { CustomerInvoiceApplicationService } from "../../customer-invoice-application.service"; import { CustomerInvoiceFullPresenter } from "../../presenters"; +import { CustomerInvoiceApplicationService } from "../../services/customer-invoice-application.service"; import { CreateCustomerInvoicePropsMapper } from "./map-dto-to-create-customer-invoice-props"; type CreateCustomerInvoiceUseCaseInput = { diff --git a/modules/customer-invoices/src/api/application/use-cases/delete-customer-invoice.use-case.ts b/modules/customer-invoices/src/api/application/use-cases/delete-customer-invoice.use-case.ts index 56dcc4bb..4499bc33 100644 --- a/modules/customer-invoices/src/api/application/use-cases/delete-customer-invoice.use-case.ts +++ b/modules/customer-invoices/src/api/application/use-cases/delete-customer-invoice.use-case.ts @@ -1,7 +1,7 @@ import { EntityNotFoundError, ITransactionManager } from "@erp/core/api"; import { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; -import { CustomerInvoiceApplicationService } from "../../application"; +import { CustomerInvoiceApplicationService } from "../services"; type DeleteCustomerInvoiceUseCaseInput = { companyId: UniqueID; diff --git a/modules/customer-invoices/src/api/application/use-cases/get-customer-invoice.use-case.ts b/modules/customer-invoices/src/api/application/use-cases/get-customer-invoice.use-case.ts index b1313034..fb457b53 100644 --- a/modules/customer-invoices/src/api/application/use-cases/get-customer-invoice.use-case.ts +++ b/modules/customer-invoices/src/api/application/use-cases/get-customer-invoice.use-case.ts @@ -1,8 +1,8 @@ import { IPresenterRegistry, ITransactionManager } from "@erp/core/api"; import { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; -import { CustomerInvoiceApplicationService } from "../../application"; import { CustomerInvoiceFullPresenter } from "../presenters/domain"; +import { CustomerInvoiceApplicationService } from "../services"; type GetCustomerInvoiceUseCaseInput = { companyId: UniqueID; diff --git a/modules/customer-invoices/src/api/application/use-cases/index.ts b/modules/customer-invoices/src/api/application/use-cases/index.ts index 95d3a470..7bd71784 100644 --- a/modules/customer-invoices/src/api/application/use-cases/index.ts +++ b/modules/customer-invoices/src/api/application/use-cases/index.ts @@ -1,3 +1,4 @@ +export * from "./change-status-customer-invoice.use-case"; export * from "./create"; export * from "./get-customer-invoice.use-case"; export * from "./issue-customer-invoice.use-case"; diff --git a/modules/customer-invoices/src/api/application/use-cases/issue-customer-invoice.use-case.ts b/modules/customer-invoices/src/api/application/use-cases/issue-customer-invoice.use-case.ts index 5796abe7..e03d6db1 100644 --- a/modules/customer-invoices/src/api/application/use-cases/issue-customer-invoice.use-case.ts +++ b/modules/customer-invoices/src/api/application/use-cases/issue-customer-invoice.use-case.ts @@ -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 { Result } from "@repo/rdx-utils"; -import { ProformaCannotBeConvertedToInvoiceError } from "../../domain"; -import { ProformaCanTranstionToIssuedSpecification } from "../../domain/specs"; -import { CustomerInvoiceApplicationService } from "../customer-invoice-application.service"; +import { + IssueCustomerInvoiceDomainService, + ProformaCustomerInvoiceDomainService, +} from "../../domain"; +import { CustomerInvoiceFullPresenter } from "../presenters"; +import { CustomerInvoiceApplicationService } from "../services"; type IssueCustomerInvoiceUseCaseInput = { companyId: UniqueID; @@ -20,21 +23,28 @@ type IssueCustomerInvoiceUseCaseInput = { * - Persiste ambas dentro de la misma transacción */ export class IssueCustomerInvoiceUseCase { + private readonly issueDomainService: IssueCustomerInvoiceDomainService; + private readonly proformaDomainService: ProformaCustomerInvoiceDomainService; + constructor( 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) { const { proforma_id, companyId } = params; - 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 presenter = this.presenterRegistry.getPresenter({ + resource: "customer-invoice", + projection: "FULL", + }) as CustomerInvoiceFullPresenter; return this.transactionManager.complete(async (transaction) => { try { @@ -44,60 +54,47 @@ export class IssueCustomerInvoiceUseCase { proformaId, transaction ); - - if (proformaResult.isFailure) { - return Result.fail(proformaResult.error); - } - + if (proformaResult.isFailure) return Result.fail(proformaResult.error); const proforma = proformaResult.data; - /** 2. Comprobamos que la proforma origen está aprovada para generar la factura */ - const isOk = new ProformaCanTranstionToIssuedSpecification(); - if (!(await isOk.isSatisfiedBy(proforma))) { - return Result.fail(new ProformaCannotBeConvertedToInvoiceError(proformaId.toString())); - } - - /** 3. Generar nueva factura */ + /** 2. Generar nueva factura */ const nextNumberResult = await this.service.getNextIssueInvoiceNumber( companyId, proforma.series, transaction ); - if (nextNumberResult.isFailure) { - return Result.fail(nextNumberResult.error); - } + if (nextNumberResult.isFailure) return Result.fail(nextNumberResult.error); - const newIssueNumber = nextNumberResult.data; - - // props base obtenidas del agregado proforma - const issuedInvoiceOrError = this.service.buildIssueInvoiceInCompany(companyId, proforma, { - invoiceNumber: newIssueNumber, - invoiceDate: UtcDate.today(), + /** 4. Crear factura definitiva (dominio) */ + const issuedInvoiceOrError = await this.issueDomainService.issueFromProforma(proforma, { + issueNumber: nextNumberResult.data, + issueDate: UtcDate.today(), }); + if (issuedInvoiceOrError.isFailure) return Result.fail(issuedInvoiceOrError.error); - if (issuedInvoiceOrError.isFailure) { - return Result.fail(issuedInvoiceOrError.error); - } - - 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( + /** 5. Guardar la nueva factura */ + const saveInvoiceResult = await this.service.createInvoiceInCompany( 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 ); - /** 5. Resultado */ - return Result.ok(issuedInvoice); + const dto = presenter.toOutput(saveInvoiceResult.data); + return Result.ok(dto); } catch (error: unknown) { return Result.fail(error as Error); } diff --git a/modules/customer-invoices/src/api/application/use-cases/list-customer-invoices.use-case.ts b/modules/customer-invoices/src/api/application/use-cases/list-customer-invoices.use-case.ts index e82c320b..e8b426d4 100644 --- a/modules/customer-invoices/src/api/application/use-cases/list-customer-invoices.use-case.ts +++ b/modules/customer-invoices/src/api/application/use-cases/list-customer-invoices.use-case.ts @@ -4,8 +4,8 @@ import { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import { Transaction } from "sequelize"; import { ListCustomerInvoicesResponseDTO } from "../../../common/dto"; -import { CustomerInvoiceApplicationService } from "../../application"; import { ListCustomerInvoicesPresenter } from "../presenters"; +import { CustomerInvoiceApplicationService } from "../services"; type ListCustomerInvoicesUseCaseInput = { companyId: UniqueID; diff --git a/modules/customer-invoices/src/api/application/use-cases/report/report-customer-invoice.use-case.ts b/modules/customer-invoices/src/api/application/use-cases/report/report-customer-invoice.use-case.ts index e1c2deb3..8c0343df 100644 --- a/modules/customer-invoices/src/api/application/use-cases/report/report-customer-invoice.use-case.ts +++ b/modules/customer-invoices/src/api/application/use-cases/report/report-customer-invoice.use-case.ts @@ -1,7 +1,7 @@ import { IPresenterRegistry, ITransactionManager } from "@erp/core/api"; import { UniqueID } from "@repo/rdx-ddd"; 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"; type ReportCustomerInvoiceUseCaseInput = { diff --git a/modules/customer-invoices/src/api/application/use-cases/update/update-customer-invoice.use-case.ts b/modules/customer-invoices/src/api/application/use-cases/update/update-customer-invoice.use-case.ts index eeccee75..7a0eb78f 100644 --- a/modules/customer-invoices/src/api/application/use-cases/update/update-customer-invoice.use-case.ts +++ b/modules/customer-invoices/src/api/application/use-cases/update/update-customer-invoice.use-case.ts @@ -4,8 +4,8 @@ import { Result } from "@repo/rdx-utils"; import { Transaction } from "sequelize"; import { UpdateCustomerInvoiceByIdRequestDTO } from "../../../../common"; import { CustomerInvoicePatchProps } from "../../../domain"; -import { CustomerInvoiceApplicationService } from "../../customer-invoice-application.service"; import { CustomerInvoiceFullPresenter } from "../../presenters"; +import { CustomerInvoiceApplicationService } from "../../services/customer-invoice-application.service"; import { mapDTOToUpdateCustomerInvoicePatchProps } from "./map-dto-to-update-customer-invoice-props"; type UpdateCustomerInvoiceUseCaseInput = { diff --git a/modules/customer-invoices/src/api/domain/aggregates/customer-invoice.ts b/modules/customer-invoices/src/api/domain/aggregates/customer-invoice.ts index 62801e90..adffa2f5 100644 --- a/modules/customer-invoices/src/api/domain/aggregates/customer-invoice.ts +++ b/modules/customer-invoices/src/api/domain/aggregates/customer-invoice.ts @@ -65,18 +65,8 @@ export interface ICustomerInvoice { hasRecipient: boolean; hasPaymentMethod: boolean; - _getSubtotalAmount(): InvoiceAmount; - getHeaderDiscountAmount(): InvoiceAmount; - - getTaxableAmount(): InvoiceAmount; - getTaxesAmount(): InvoiceAmount; - getTotalAmount(): InvoiceAmount; - getTaxes(): InvoiceTaxTotal[]; - - asIssued(): Result; - - getIssuedInvoiceProps(): CustomerInvoiceProps; + getProps(): CustomerInvoiceProps; } export class CustomerInvoice @@ -94,15 +84,19 @@ export class CustomerInvoice currencyCode: props.currencyCode, }); } + getHeaderDiscountAmount(): InvoiceAmount { throw new Error("Method not implemented."); } + getTaxableAmount(): InvoiceAmount { throw new Error("Method not implemented."); } + getTaxesAmount(): InvoiceAmount { throw new Error("Method not implemented."); } + getTotalAmount(): InvoiceAmount { throw new Error("Method not implemented."); } @@ -348,21 +342,7 @@ export class CustomerInvoice }; } - public asIssued(): Result { - const newProps: CustomerInvoiceProps = { - ...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(), - }; + public getProps(): CustomerInvoiceProps { + return this.props; } } diff --git a/modules/customer-invoices/src/api/domain/entities/customer-invoice-items/customer-invoice-item.ts b/modules/customer-invoices/src/api/domain/entities/customer-invoice-items/customer-invoice-item.ts index 57a48703..1af1dd08 100644 --- a/modules/customer-invoices/src/api/domain/entities/customer-invoice-items/customer-invoice-item.ts +++ b/modules/customer-invoices/src/api/domain/entities/customer-invoice-items/customer-invoice-item.ts @@ -6,7 +6,7 @@ import { ItemDiscount, ItemQuantity, } from "../../value-objects"; -import { ItemTaxTotal, ItemTaxes } from "../item-taxes"; +import { ItemTaxes, ItemTaxTotal } from "../item-taxes"; export interface CustomerInvoiceItemProps { description: Maybe; diff --git a/modules/customer-invoices/src/api/domain/errors/entity-is-not-proforma-error.ts b/modules/customer-invoices/src/api/domain/errors/entity-is-not-proforma-error.ts new file mode 100644 index 00000000..783d6177 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/errors/entity-is-not-proforma-error.ts @@ -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; diff --git a/modules/customer-invoices/src/api/domain/errors/index.ts b/modules/customer-invoices/src/api/domain/errors/index.ts index 7997156f..14e406c5 100644 --- a/modules/customer-invoices/src/api/domain/errors/index.ts +++ b/modules/customer-invoices/src/api/domain/errors/index.ts @@ -1,2 +1,4 @@ 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"; diff --git a/modules/customer-invoices/src/api/domain/errors/invalid-proforma-transition-error.ts b/modules/customer-invoices/src/api/domain/errors/invalid-proforma-transition-error.ts new file mode 100644 index 00000000..440c8782 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/errors/invalid-proforma-transition-error.ts @@ -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; diff --git a/modules/customer-invoices/src/api/domain/repositories/customer-invoice-repository.interface.ts b/modules/customer-invoices/src/api/domain/repositories/customer-invoice-repository.interface.ts index 59283190..b249463b 100644 --- a/modules/customer-invoices/src/api/domain/repositories/customer-invoice-repository.interface.ts +++ b/modules/customer-invoices/src/api/domain/repositories/customer-invoice-repository.interface.ts @@ -3,6 +3,7 @@ import { UniqueID } from "@repo/rdx-ddd"; import { Collection, Result } from "@repo/rdx-utils"; import { CustomerInvoiceListDTO } from "../../infrastructure"; import { CustomerInvoice } from "../aggregates"; +import { CustomerInvoiceStatus } from "../value-objects"; /** * Interfaz del repositorio para el agregado `CustomerInvoice`. @@ -80,4 +81,21 @@ export interface ICustomerInvoiceRepository { id: UniqueID, transaction: unknown ): Promise>; + + /** + * + * 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 + */ + updateProformaStatusByIdInCompany( + companyId: UniqueID, + id: UniqueID, + newStatus: CustomerInvoiceStatus, + transaction: unknown + ): Promise>; } diff --git a/modules/customer-invoices/src/api/domain/services/index.ts b/modules/customer-invoices/src/api/domain/services/index.ts index e34a6999..0f7cf9cf 100644 --- a/modules/customer-invoices/src/api/domain/services/index.ts +++ b/modules/customer-invoices/src/api/domain/services/index.ts @@ -1 +1,3 @@ export * from "./customer-invoice-number-generator.interface"; +export * from "./issue-customer-invoice-domain-service"; +export * from "./proforma-customer-invoice-domain-service"; diff --git a/modules/customer-invoices/src/api/domain/services/issue-customer-invoice-domain-service.ts b/modules/customer-invoices/src/api/domain/services/issue-customer-invoice-domain-service.ts new file mode 100644 index 00000000..0ce7f88c --- /dev/null +++ b/modules/customer-invoices/src/api/domain/services/issue-customer-invoice-domain-service.ts @@ -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 {} + + /** + * 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 - Nueva factura emitida o error de dominio. + */ + public async issueFromProforma( + proforma: CustomerInvoice, + params: { + issueNumber: CustomerInvoiceNumber; + issueDate: UtcDate; + } + ): Promise> { + 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); + } +} diff --git a/modules/customer-invoices/src/api/domain/services/proforma-customer-invoice-domain-service.ts b/modules/customer-invoices/src/api/domain/services/proforma-customer-invoice-domain-service.ts new file mode 100644 index 00000000..9eb0a25b --- /dev/null +++ b/modules/customer-invoices/src/api/domain/services/proforma-customer-invoice-domain-service.ts @@ -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> { + // 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> { + return this.transition(proforma, INVOICE_STATUS.SENT); + } + + /** Aprueba la proforma (sent → approved) */ + async approve(proforma: CustomerInvoice): Promise> { + return this.transition(proforma, INVOICE_STATUS.APPROVED); + } + + /** Rechaza la proforma (sent → rejected) */ + async reject(proforma: CustomerInvoice): Promise> { + return this.transition(proforma, INVOICE_STATUS.REJECTED); + } + + /** Reabre una proforma rechazada (rejected → draft) */ + async reopen(proforma: CustomerInvoice): Promise> { + return this.transition(proforma, INVOICE_STATUS.DRAFT); + } + + /** Marca la proforma como emitida (approved → issued) */ + async markAsIssued(proforma: CustomerInvoice): Promise> { + return this.transition(proforma, INVOICE_STATUS.ISSUED); + } +} diff --git a/modules/customer-invoices/src/api/domain/specs/customer-invoice-is-proforma.specification.ts b/modules/customer-invoices/src/api/domain/specs/customer-invoice-is-proforma.specification.ts new file mode 100644 index 00000000..640854b5 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/specs/customer-invoice-is-proforma.specification.ts @@ -0,0 +1,8 @@ +import { CompositeSpecification } from "@repo/rdx-ddd"; +import { CustomerInvoice } from "../aggregates"; + +export class CustomerInvoiceIsProformaSpecification extends CompositeSpecification { + public async isSatisfiedBy(proforma: CustomerInvoice): Promise { + return proforma.isProforma; + } +} diff --git a/modules/customer-invoices/src/api/domain/specs/index.ts b/modules/customer-invoices/src/api/domain/specs/index.ts index dc3b63c7..59eb6146 100644 --- a/modules/customer-invoices/src/api/domain/specs/index.ts +++ b/modules/customer-invoices/src/api/domain/specs/index.ts @@ -1 +1,2 @@ +export * from "./customer-invoice-is-proforma.specification"; export * from "./proforma-can-transtion-to-issued.specification"; diff --git a/modules/customer-invoices/src/api/domain/specs/proforma-can-transtion-to-issued.specification.ts b/modules/customer-invoices/src/api/domain/specs/proforma-can-transtion-to-issued.specification.ts index 62fa6deb..0cd58f96 100644 --- a/modules/customer-invoices/src/api/domain/specs/proforma-can-transtion-to-issued.specification.ts +++ b/modules/customer-invoices/src/api/domain/specs/proforma-can-transtion-to-issued.specification.ts @@ -4,6 +4,6 @@ import { INVOICE_STATUS } from "../value-objects"; export class ProformaCanTranstionToIssuedSpecification extends CompositeSpecification { public async isSatisfiedBy(proforma: CustomerInvoice): Promise { - return proforma.isProforma && proforma.canTransitionTo(INVOICE_STATUS.ISSUED); + return proforma.canTransitionTo(INVOICE_STATUS.ISSUED); } } diff --git a/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-status.ts b/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-status.ts index 61d4708b..f0c958a5 100644 --- a/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-status.ts +++ b/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-status.ts @@ -15,18 +15,20 @@ export enum INVOICE_STATUS { // status === "issued" <- (si is_proforma === false) => Factura y enviará/enviada a Veri*Factu ISSUED = "issued", } + +const INVOICE_TRANSITIONS: Record = { + draft: [INVOICE_STATUS.SENT], + sent: [INVOICE_STATUS.APPROVED, INVOICE_STATUS.REJECTED], + approved: [INVOICE_STATUS.ISSUED], + rejected: [INVOICE_STATUS.DRAFT], + issued: [], +}; + export class CustomerInvoiceStatus extends ValueObject { 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 = { - draft: [INVOICE_STATUS.SENT], - sent: [INVOICE_STATUS.APPROVED, INVOICE_STATUS.REJECTED], - approved: [INVOICE_STATUS.ISSUED], - rejected: [INVOICE_STATUS.DRAFT], - }; - static create(value: string): Result { if (!CustomerInvoiceStatus.ALLOWED_STATUSES.includes(value)) { const detail = `Estado de la factura no válido: ${value}`; @@ -89,7 +91,7 @@ export class CustomerInvoiceStatus extends ValueObject any; };*/ @@ -25,9 +24,6 @@ export const customerInvoicesAPIModule: IModuleServer = { label: this.name, }); - logger.info(listServices()); - //getService() - const deps = buildCustomerInvoiceDependencies(params); return { @@ -38,9 +34,9 @@ export const customerInvoicesAPIModule: IModuleServer = { invoiceId: UniqueID, transaction?: Transaction ) => { - const { service } = deps; + /*const { service } = deps; - return service.getInvoiceByIdInCompany(companyId, invoiceId, transaction); + return service.getInvoiceByIdInCompany(companyId, invoiceId, transaction);*/ }, }, }; diff --git a/modules/customer-invoices/src/api/infrastructure/dependencies.bak b/modules/customer-invoices/src/api/infrastructure/dependencies.bak new file mode 100644 index 00000000..1318aecc --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/dependencies.bak @@ -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, + }; +} diff --git a/modules/customer-invoices/src/api/infrastructure/dependencies.ts b/modules/customer-invoices/src/api/infrastructure/dependencies.ts index 1318aecc..a5bc02f3 100644 --- a/modules/customer-invoices/src/api/infrastructure/dependencies.ts +++ b/modules/customer-invoices/src/api/infrastructure/dependencies.ts @@ -8,6 +8,7 @@ import { SequelizeTransactionManager, } from "@erp/core/api"; import { + ChangeStatusCustomerInvoiceUseCase, CreateCustomerInvoiceUseCase, CustomerInvoiceApplicationService, CustomerInvoiceFullPresenter, @@ -33,11 +34,11 @@ export type CustomerInvoiceDeps = { mapperRegistry: IMapperRegistry; presenterRegistry: IPresenterRegistry; repo: CustomerInvoiceRepository; - service: CustomerInvoiceApplicationService; + appService: CustomerInvoiceApplicationService; catalogs: { taxes: JsonTaxCatalogProvider; }; - build: { + useCases: { list: () => ListCustomerInvoicesUseCase; get: () => GetCustomerInvoiceUseCase; create: () => CreateCustomerInvoiceUseCase; @@ -45,17 +46,19 @@ export type CustomerInvoiceDeps = { //delete: () => DeleteCustomerInvoiceUseCase; report: () => ReportCustomerInvoiceUseCase; issue: () => IssueCustomerInvoiceUseCase; + changeStatus: () => ChangeStatusCustomerInvoiceUseCase; }; - getService: (name: string) => any; - listServices: () => string[]; }; export function buildCustomerInvoiceDependencies(params: ModuleParams): CustomerInvoiceDeps { - const { database, listServices, getService } = params; - const transactionManager = new SequelizeTransactionManager(database); + const { database } = params; + + /** Dominio */ const catalogs = { taxes: SpainTaxCatalogProvider() }; - // Mapper Registry + /** Infraestructura */ + const transactionManager = new SequelizeTransactionManager(database); + const mapperRegistry = new InMemoryMapperRegistry(); mapperRegistry .registerDomainMapper( @@ -70,100 +73,74 @@ export function buildCustomerInvoiceDependencies(params: ModuleParams): Customer ]); // Repository & Services - const repo = new CustomerInvoiceRepository({ mapperRegistry, database }); + const repository = new CustomerInvoiceRepository({ mapperRegistry, database }); const numberGenerator = new SequelizeInvoiceNumberGenerator(); - const service = new CustomerInvoiceApplicationService(repo, numberGenerator); + + /** Aplicación */ + const appService = new CustomerInvoiceApplicationService(repository, numberGenerator); // Presenter Registry const presenterRegistry = new InMemoryPresenterRegistry(); presenterRegistry.registerPresenters([ { - key: { - resource: "customer-invoice-items", - projection: "FULL", - }, + key: { resource: "customer-invoice-items", projection: "FULL" }, presenter: new CustomerInvoiceItemsFullPresenter(presenterRegistry), }, { - key: { - resource: "recipient-invoice", - projection: "FULL", - }, + key: { resource: "recipient-invoice", projection: "FULL" }, presenter: new RecipientInvoiceFullPresenter(presenterRegistry), }, { - key: { - resource: "customer-invoice", - projection: "FULL", - }, + key: { resource: "customer-invoice", projection: "FULL" }, presenter: new CustomerInvoiceFullPresenter(presenterRegistry), }, { - key: { - resource: "customer-invoice", - projection: "LIST", - }, + key: { resource: "customer-invoice", projection: "LIST" }, presenter: new ListCustomerInvoicesPresenter(presenterRegistry), }, { - key: { - resource: "customer-invoice", - projection: "REPORT", - format: "JSON", - }, + key: { resource: "customer-invoice", projection: "REPORT", format: "JSON" }, presenter: new CustomerInvoiceReportPresenter(presenterRegistry), }, { - key: { - resource: "customer-invoice-items", - projection: "REPORT", - format: "JSON", - }, + key: { resource: "customer-invoice-items", projection: "REPORT", format: "JSON" }, presenter: new CustomerInvoiceItemsReportPersenter(presenterRegistry), }, { - key: { - resource: "customer-invoice", - projection: "REPORT", - format: "HTML", - }, + key: { resource: "customer-invoice", projection: "REPORT", format: "HTML" }, presenter: new CustomerInvoiceReportHTMLPresenter(presenterRegistry), }, { - key: { - resource: "customer-invoice", - projection: "REPORT", - format: "PDF", - }, + key: { resource: "customer-invoice", projection: "REPORT", format: "PDF" }, presenter: new CustomerInvoiceReportPDFPresenter(presenterRegistry), }, ]); + const useCases = { + list: () => new ListCustomerInvoicesUseCase(appService, transactionManager, presenterRegistry), + get: () => new GetCustomerInvoiceUseCase(appService, transactionManager, presenterRegistry), + create: () => + new CreateCustomerInvoiceUseCase( + appService, + transactionManager, + presenterRegistry, + catalogs.taxes + ), + update: () => + new UpdateCustomerInvoiceUseCase(appService, transactionManager, presenterRegistry), + report: () => + new ReportCustomerInvoiceUseCase(appService, transactionManager, presenterRegistry), + issue: () => new IssueCustomerInvoiceUseCase(appService, transactionManager, presenterRegistry), + changeStatus: () => new ChangeStatusCustomerInvoiceUseCase(appService, transactionManager), + }; + return { transactionManager, - repo, + repo: repository, mapperRegistry, presenterRegistry, - service, + appService, 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, + useCases, }; } diff --git a/modules/customer-invoices/src/api/infrastructure/express/controllers/change-status-customer-invoice.controller.ts b/modules/customer-invoices/src/api/infrastructure/express/controllers/change-status-customer-invoice.controller.ts new file mode 100644 index 00000000..38495110 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/express/controllers/change-status-customer-invoice.controller.ts @@ -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 { + 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) + ); + } +} diff --git a/modules/customer-invoices/src/api/infrastructure/express/controllers/index.ts b/modules/customer-invoices/src/api/infrastructure/express/controllers/index.ts index 9542fb50..87230f1d 100644 --- a/modules/customer-invoices/src/api/infrastructure/express/controllers/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/express/controllers/index.ts @@ -1,7 +1,8 @@ +export * from "./change-status-customer-invoice.controller"; export * from "./create-customer-invoice.controller"; //export * from "./delete-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 "./report-customer-invoice.controller"; export * from "./update-customer-invoice.controller"; diff --git a/modules/customer-invoices/src/api/infrastructure/express/customer-invoices-api-error-mapper.ts b/modules/customer-invoices/src/api/infrastructure/express/customer-invoices-api-error-mapper.ts index 70b3ca3a..d100f2be 100644 --- a/modules/customer-invoices/src/api/infrastructure/express/customer-invoices-api-error-mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/express/customer-invoices-api-error-mapper.ts @@ -9,7 +9,9 @@ import { } from "@erp/core/api"; import { CustomerInvoiceIdAlreadyExistsError, + EntityIsNotProformaError, isCustomerInvoiceIdAlreadyExistsError, + isEntityIsNotProformaError, isProformaCannotBeConvertedToInvoiceError, ProformaCannotBeConvertedToInvoiceError, } 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 = { priority: 120, matches: (e) => isProformaCannotBeConvertedToInvoiceError(e), @@ -38,4 +49,5 @@ const proformaConversionRule: ErrorToApiRule = { // Cómo aplicarla: crea una nueva instancia del mapper con la regla extra export const customerInvoicesApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default() .register(invoiceDuplicateRule) + .register(entityIsNotProformaError) .register(proformaConversionRule); diff --git a/modules/customer-invoices/src/api/infrastructure/express/customer-invoices.routes.ts b/modules/customer-invoices/src/api/infrastructure/express/customer-invoices.routes.ts index d7c09420..93a598c7 100644 --- a/modules/customer-invoices/src/api/infrastructure/express/customer-invoices.routes.ts +++ b/modules/customer-invoices/src/api/infrastructure/express/customer-invoices.routes.ts @@ -4,6 +4,8 @@ import { ILogger } from "@repo/rdx-logger"; import { Application, NextFunction, Request, Response, Router } from "express"; import { Sequelize } from "sequelize"; import { + ChangeStatusCustomerInvoiceByIdParamsRequestSchema, + ChangeStatusCustomerInvoiceByIdRequestSchema, CreateCustomerInvoiceRequestSchema, CustomerInvoiceListRequestSchema, GetCustomerInvoiceByIdRequestSchema, @@ -13,6 +15,7 @@ import { } from "../../../common/dto"; import { buildCustomerInvoiceDependencies } from "../dependencies"; import { + ChangeStatusCustomerInvoiceController, CreateCustomerInvoiceController, GetCustomerInvoiceController, ListCustomerInvoicesController, @@ -55,7 +58,7 @@ export const customerInvoicesRouter = (params: ModuleParams) => { //checkTabContext, validateRequest(CustomerInvoiceListRequestSchema, "params"), 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 */); return controller.execute(req, res, next); } @@ -66,7 +69,7 @@ export const customerInvoicesRouter = (params: ModuleParams) => { //checkTabContext, validateRequest(GetCustomerInvoiceByIdRequestSchema, "params"), (req: Request, res: Response, next: NextFunction) => { - const useCase = deps.build.get(); + const useCase = deps.useCases.get(); const controller = new GetCustomerInvoiceController(useCase); return controller.execute(req, res, next); } @@ -78,7 +81,7 @@ export const customerInvoicesRouter = (params: ModuleParams) => { validateRequest(CreateCustomerInvoiceRequestSchema, "body"), (req: Request, res: Response, next: NextFunction) => { - const useCase = deps.build.create(); + const useCase = deps.useCases.create(); const controller = new CreateCustomerInvoiceController(useCase); return controller.execute(req, res, next); } @@ -91,7 +94,7 @@ export const customerInvoicesRouter = (params: ModuleParams) => { validateRequest(UpdateCustomerInvoiceByIdParamsRequestSchema, "params"), validateRequest(UpdateCustomerInvoiceByIdRequestSchema, "body"), (req: Request, res: Response, next: NextFunction) => { - const useCase = deps.build.update(); + const useCase = deps.useCases.update(); const controller = new UpdateCustomerInvoiceController(useCase); return controller.execute(req, res, next); } @@ -103,7 +106,7 @@ export const customerInvoicesRouter = (params: ModuleParams) => { validateRequest(DeleteCustomerInvoiceByIdRequestSchema, "params"), (req: Request, res: Response, next: NextFunction) => { - const useCase = deps.build.delete(); + const useCase = deps.useCases.delete(); const controller = new DeleteCustomerInvoiceController(useCase); return controller.execute(req, res, next); } @@ -114,12 +117,26 @@ export const customerInvoicesRouter = (params: ModuleParams) => { //checkTabContext, validateRequest(ReportCustomerInvoiceByIdRequestSchema, "params"), (req: Request, res: Response, next: NextFunction) => { - const useCase = deps.build.report(); + const useCase = deps.useCases.report(); const controller = new ReportCustomerInvoiceController(useCase); 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( "/:proforma_id/issue", //checkTabContext, @@ -128,7 +145,7 @@ export const customerInvoicesRouter = (params: ModuleParams) => { validateRequest(XXX, "body"),*/ (req: Request, res: Response, next: NextFunction) => { - const useCase = deps.build.issue(); + const useCase = deps.useCases.issue(); const controller = new IssueCustomerInvoiceController(useCase); return controller.execute(req, res, next); } diff --git a/modules/customer-invoices/src/api/infrastructure/index.ts b/modules/customer-invoices/src/api/infrastructure/index.ts index f35c8878..c86dd682 100644 --- a/modules/customer-invoices/src/api/infrastructure/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/index.ts @@ -1,3 +1,4 @@ +export * from "./dependencies"; export * from "./express"; export * from "./mappers"; export * from "./sequelize"; diff --git a/modules/customer-invoices/src/api/infrastructure/mappers/domain/customer-invoice.mapper.ts b/modules/customer-invoices/src/api/infrastructure/mappers/domain/customer-invoice.mapper.ts index 4c821fc9..57be06df 100644 --- a/modules/customer-invoices/src/api/infrastructure/mappers/domain/customer-invoice.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/mappers/domain/customer-invoice.mapper.ts @@ -313,6 +313,7 @@ export class CustomerInvoiceDomainMapper parent: source, ...params, }); + if (itemsResult.isFailure) { errors.push({ path: "items", @@ -320,14 +321,13 @@ export class CustomerInvoiceDomainMapper }); } - const items = itemsResult.data; - // 2) Taxes const taxesResult = this._taxesMapper.mapToPersistenceArray(new Collection(source.getTaxes()), { errors, parent: source, ...params, }); + if (taxesResult.isFailure) { errors.push({ path: "taxes", @@ -335,25 +335,25 @@ export class CustomerInvoiceDomainMapper }); } - const taxes = taxesResult.data; - - // 3) Calcular totales - const allAmounts = source.getAllAmounts(); - - // 4) Cliente + // 3) Cliente const recipient = this._recipientMapper.mapToPersistence(source.recipient, { errors, parent: source, ...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) { return Result.fail( 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 = { // Identificación id: source.id.toPrimitive(), @@ -381,8 +381,8 @@ export class CustomerInvoiceDomainMapper discount_percentage_value: source.discountPercentage.toPrimitive().value, discount_percentage_scale: source.discountPercentage.toPrimitive().scale, - discount_amount_value: allAmounts.discountAmount.value, - discount_amount_scale: allAmounts.discountAmount.scale, + discount_amount_value: allAmounts.headerDiscountAmount.value, + discount_amount_scale: allAmounts.headerDiscountAmount.scale, taxable_amount_value: allAmounts.taxableAmount.value, taxable_amount_scale: allAmounts.taxableAmount.scale, diff --git a/modules/customer-invoices/src/api/infrastructure/sequelize/customer-invoice.repository.ts b/modules/customer-invoices/src/api/infrastructure/sequelize/customer-invoice.repository.ts index ba0c5633..81b821ec 100644 --- a/modules/customer-invoices/src/api/infrastructure/sequelize/customer-invoice.repository.ts +++ b/modules/customer-invoices/src/api/infrastructure/sequelize/customer-invoice.repository.ts @@ -8,7 +8,7 @@ import { Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/serve import { UniqueID } from "@repo/rdx-ddd"; import { Collection, Result } from "@repo/rdx-utils"; import { Transaction } from "sequelize"; -import { CustomerInvoice, ICustomerInvoiceRepository } from "../../domain"; +import { CustomerInvoice, CustomerInvoiceStatus, ICustomerInvoiceRepository } from "../../domain"; import { CustomerInvoiceListDTO, ICustomerInvoiceDomainMapper, @@ -102,7 +102,13 @@ export class CustomerInvoiceRepository }); const dto = mapper.mapToPersistence(invoice); + if (dto.isFailure) { + return Result.fail(dto.error); + } const { id, ...updatePayload } = dto.data; + + console.log(id); + const [affected] = await CustomerInvoiceModel.update(updatePayload, { where: { id /*, version */ }, //fields: Object.keys(updatePayload), @@ -110,6 +116,8 @@ export class CustomerInvoiceRepository individualHooks: true, }); + console.log(affected); + if (affected === 0) { return Result.fail( new InfrastructureRepositoryError( @@ -332,4 +340,49 @@ export class CustomerInvoiceRepository 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 + */ + async updateProformaStatusByIdInCompany( + companyId: UniqueID, + id: UniqueID, + newStatus: CustomerInvoiceStatus, + transaction: Transaction + ): Promise> { + 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)); + } + } } diff --git a/modules/customer-invoices/src/common/dto/request/change-status-customer-invoice-by-id.request.dto.ts b/modules/customer-invoices/src/common/dto/request/change-status-customer-invoice-by-id.request.dto.ts new file mode 100644 index 00000000..add2ba41 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/request/change-status-customer-invoice-by-id.request.dto.ts @@ -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 +>; diff --git a/modules/customer-invoices/src/common/dto/request/index.ts b/modules/customer-invoices/src/common/dto/request/index.ts index 40defeed..6647c31a 100644 --- a/modules/customer-invoices/src/common/dto/request/index.ts +++ b/modules/customer-invoices/src/common/dto/request/index.ts @@ -1,3 +1,4 @@ +export * from "./change-status-customer-invoice-by-id.request.dto"; export * from "./create-customer-invoice.request.dto"; export * from "./customer-invoices-list.request.dto"; export * from "./delete-customer-invoice-by-id.request.dto"; diff --git a/modules/customer-invoices/src/common/dto/response/get-customer-invoice-by-id.response.dto.ts b/modules/customer-invoices/src/common/dto/response/get-customer-invoice-by-id.response.dto.ts index 39071fb0..d9b81fbf 100644 --- a/modules/customer-invoices/src/common/dto/response/get-customer-invoice-by-id.response.dto.ts +++ b/modules/customer-invoices/src/common/dto/response/get-customer-invoice-by-id.response.dto.ts @@ -5,6 +5,7 @@ export const GetCustomerInvoiceByIdResponseSchema = z.object({ id: z.uuid(), company_id: z.uuid(), + is_proforma: z.string(), invoice_number: z.string(), status: z.string(), series: z.string(), diff --git a/modules/customers/package.json b/modules/customers/package.json index 5747027d..41506ac2 100644 --- a/modules/customers/package.json +++ b/modules/customers/package.json @@ -40,6 +40,7 @@ "@repo/shadcn-ui": "workspace:*", "@tanstack/react-query": "^5.90.6", "@tanstack/react-table": "^8.21.3", + "express": "^4.18.2", "i18next": "^25.6.0", "lucide-react": "^0.503.0", "react-data-table-component": "^7.7.0", diff --git a/modules/customers/src/api/infrastructure/express/customers.routes.ts b/modules/customers/src/api/infrastructure/express/customers.routes.ts index 12f7dad5..4ab62530 100644 --- a/modules/customers/src/api/infrastructure/express/customers.routes.ts +++ b/modules/customers/src/api/infrastructure/express/customers.routes.ts @@ -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 { ILogger } from "@repo/rdx-logger"; import { Application, NextFunction, Request, Response, Router } from "express"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 442e6702..8c9a3e4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -586,6 +586,9 @@ importers: '@tanstack/react-table': specifier: ^8.21.3 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: specifier: ^25.6.0 version: 25.6.0(typescript@5.9.3)