From 1c0235de754a0eb6562457140d27ef4dafcaa206 Mon Sep 17 00:00:00 2001 From: david Date: Thu, 9 Jul 2026 13:07:48 +0200 Subject: [PATCH] feat: Enhance proforma issuance with tax regime support - Added tax regime to ProformaIssueReadModel and related services. - Implemented tax regime resolution in ProformaToIssuedInvoiceConverter. - Updated IssuedInvoiceFullSnapshotBuilder to include tax regime details. - Enhanced ProformaIssueReadModelAssembler to handle tax regime fetching. - Introduced IssueProformaResponseSnapshotBuilder for response mapping. - Updated routes and controllers to accommodate new proforma issuance logic. - Refactored request and response DTOs to include tax regime information. - Added command structure for issuing proformas. --- .../models/proforma-issue-read.model.ts | 6 ++- ...forma-to-issued-invoice-props-converter.ts | 21 +++++++++ .../issued-invoice-full-snapshot-builder.ts | 6 +++ .../issued-invoice-full-snapshot.interface.ts | 4 ++ .../application/proformas/commands/index.ts | 1 + .../commands/issue-proforma.command.ts | 5 ++ .../proformas/di/proforma-issuer.di.ts | 4 +- .../di/proforma-snapshot-builders.di.ts | 3 ++ .../proformas/di/proforma-use-cases.di.ts | 4 ++ .../src/api/application/proformas/index.ts | 1 + .../mappers/create-proforma-input.mapper.ts | 7 --- .../proforma-issue-read-model.assembler.ts | 46 ++++++++++++++++++- .../proformas/snapshot-builders/index.ts | 1 + .../snapshot-builders/issue/index.ts | 1 + ...ssue-proforma-response.snapshot-builder.ts | 39 ++++++++++++++++ .../use-cases/issue-proforma.use-case.ts | 26 +++++------ .../aggregates/issued-invoice.aggregate.ts | 16 +++++++ .../sequelize-issued-invoice-domain.mapper.ts | 23 ++++++++++ .../proformas/di/proformas.di.ts | 2 + .../controllers/issue-proforma.controller.ts | 26 +++++++++-- .../infrastructure/proformas/express/index.ts | 1 + .../proformas/express/mappers/index.ts | 1 + .../issue-proforma-by-id-request.mapper.ts | 27 +++++++++++ .../proformas/express/proformas.routes.ts | 32 +++++++++---- ...elize-proforma-number-generator.service.ts | 43 +++++++++++------ .../proformas/create-proforma.request.dto.ts | 2 +- .../issue-proforma-by-id.request.dto.ts | 10 +++- .../get-issued-invoice-by-id.response.dto.ts | 2 +- .../issue-proforma-by-id.response.dto.ts | 32 +++++++++++-- .../common/dto/shared/tax-regime-ref.dto.ts | 2 +- 30 files changed, 337 insertions(+), 57 deletions(-) create mode 100644 modules/customer-invoices/src/api/application/proformas/commands/index.ts create mode 100644 modules/customer-invoices/src/api/application/proformas/commands/issue-proforma.command.ts create mode 100644 modules/customer-invoices/src/api/application/proformas/snapshot-builders/issue/index.ts create mode 100644 modules/customer-invoices/src/api/application/proformas/snapshot-builders/issue/issue-proforma-response.snapshot-builder.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/proformas/express/mappers/index.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/proformas/express/mappers/issue-proforma-by-id-request.mapper.ts diff --git a/modules/customer-invoices/src/api/application/issued-invoices/models/proforma-issue-read.model.ts b/modules/customer-invoices/src/api/application/issued-invoices/models/proforma-issue-read.model.ts index 43c9d073..2f97edce 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/models/proforma-issue-read.model.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/models/proforma-issue-read.model.ts @@ -1,5 +1,8 @@ import type { Proforma } from "../../../domain"; -import type { InvoicePaymentMethodReadModel } from "../../common/models"; +import type { + InvocieTaxRegimeFullReadModel, + InvoicePaymentMethodReadModel, +} from "../../common/models"; /** * Modelo de lectura usado exclusivamente durante la emisión de una proforma. @@ -10,4 +13,5 @@ import type { InvoicePaymentMethodReadModel } from "../../common/models"; export interface ProformaIssueReadModel { proforma: Proforma; paymentMethod: InvoicePaymentMethodReadModel; + taxRegime: InvocieTaxRegimeFullReadModel; } diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts index f936d8c4..7dd71673 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts @@ -7,6 +7,7 @@ import { type IIssuedInvoiceCreateProps, InvoicePaymentMethod, InvoiceStatus, + InvoiceTaxRegime, IssuedInvoiceItem, IssuedInvoiceRecipient, IssuedInvoiceTax, @@ -57,6 +58,12 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic return Result.fail(paymentResult.error); } + const taxRegimeResult = this.resolveTaxRegime(source); + + if (taxRegimeResult.isFailure) { + return Result.fail(taxRegimeResult.error); + } + const proformaTotals = proforma.totals(); return Result.ok({ @@ -78,6 +85,7 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic reference: proforma.reference, paymentMethod: paymentResult.data, + taxRegime: taxRegimeResult.data, customerId: proforma.customerId, recipient: recipientResult.data, @@ -251,4 +259,17 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic return Result.ok(paymentMethodResult.data); } + + private resolveTaxRegime(source: ProformaIssueReadModel): Result { + const taxRegimeResult = InvoiceTaxRegime.create({ + code: source.taxRegime.code, + description: source.taxRegime.description || source.taxRegime.code, + }); + + if (taxRegimeResult.isFailure) { + return Result.fail(taxRegimeResult.error); + } + + return Result.ok(taxRegimeResult.data); + } } diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot-builder.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot-builder.ts index c70aa9aa..1ffef9f3 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot-builder.ts @@ -31,6 +31,11 @@ export class IssuedInvoiceFullSnapshotBuilder implements IIssuedInvoiceFullSnaps payment_description: invoice.paymentMethod.name.toString(), }; + const tax_regime = { + code: invoice.taxRegime.code, + description: invoice.taxRegime.description, + }; + return { id: invoice.id.toString(), company_id: invoice.companyId.toString(), @@ -58,6 +63,7 @@ export class IssuedInvoiceFullSnapshotBuilder implements IIssuedInvoiceFullSnaps taxes, payment_method, + tax_regime, subtotal_amount: invoice.subtotalAmount.toObjectString(), diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot.interface.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot.interface.ts index 0b6ba1b4..e3e6de5c 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot.interface.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot.interface.ts @@ -44,6 +44,10 @@ export interface IIssuedInvoiceFullSnapshot { payment_id: string; payment_description: string; } | null; + tax_regime: { + code: string; + description: string | null; + }; subtotal_amount: { value: string; scale: string; currency_code: string }; diff --git a/modules/customer-invoices/src/api/application/proformas/commands/index.ts b/modules/customer-invoices/src/api/application/proformas/commands/index.ts new file mode 100644 index 00000000..41c8011d --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/commands/index.ts @@ -0,0 +1 @@ +export * from "./issue-proforma.command"; diff --git a/modules/customer-invoices/src/api/application/proformas/commands/issue-proforma.command.ts b/modules/customer-invoices/src/api/application/proformas/commands/issue-proforma.command.ts new file mode 100644 index 00000000..eba69586 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/commands/issue-proforma.command.ts @@ -0,0 +1,5 @@ +export type IssueProformaCommand = { + companyId: string; + proformaId: string; + actorUserId?: string; +}; diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-issuer.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-issuer.di.ts index a405a8f4..28f1a34c 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-issuer.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-issuer.di.ts @@ -1,4 +1,4 @@ -import type { IPaymentMethodPublicFinder } from "@erp/catalogs/api"; +import type { IPaymentMethodPublicFinder, ITaxRegimePublicFinder } from "@erp/catalogs/api"; import type { IProformaToIssuedInvoiceConverter } from "../../issued-invoices"; import { type IProformaIssuer, ProformaIssueReadModelAssembler, ProformaIssuer } from "../services"; @@ -13,8 +13,10 @@ export const buildProformaIssuer = (params: { export const buildProformaIssueReadModelAssembler = (params: { paymentMethodFinder: IPaymentMethodPublicFinder; + taxRegimeFinder: ITaxRegimePublicFinder; }) => { return new ProformaIssueReadModelAssembler({ paymentMethodFinder: params.paymentMethodFinder, + taxRegimeFinder: params.taxRegimeFinder, }); }; diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-snapshot-builders.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-snapshot-builders.di.ts index a4a50edf..a391fa12 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-snapshot-builders.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-snapshot-builders.di.ts @@ -1,6 +1,7 @@ // application/issued-invoices/di/snapshot-builders.di.ts import { + IssueProformaResponseSnapshotBuilder, ProformaFullSnapshotBuilder, ProformaItemReportSnapshotBuilder, ProformaItemsFullSnapshotBuilder, @@ -32,9 +33,11 @@ export function buildProformaSnapshotBuilders() { itemsReportBuilder, taxesReportBuilder ); + const issueResponseSnapshotBuilder = new IssueProformaResponseSnapshotBuilder(); return { full: fullSnapshotBuilder, + issue: issueResponseSnapshotBuilder, summary: summarySnapshotBuilder, report: reportSnapshotBuilder, }; diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts index 05c103ce..a6e22190 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts @@ -16,6 +16,7 @@ import type { } from "../services"; import type { IProformaFullSnapshotBuilder, + IIssueProformaResponseSnapshotBuilder, IProformaReportSnapshotBuilder, IProformaSummarySnapshotBuilder, } from "../snapshot-builders"; @@ -121,6 +122,7 @@ export function buildIssueProformaUseCase(deps: { finder: IProformaFinder; issuer: IProformaIssuer; issueReadModelAssembler: IProformaIssueReadModelAssembler; + issueResponseSnapshotBuilder: IIssueProformaResponseSnapshotBuilder; repository: IProformaRepository; transactionManager: ITransactionManager; }) { @@ -128,6 +130,7 @@ export function buildIssueProformaUseCase(deps: { finder, issuer, issueReadModelAssembler, + issueResponseSnapshotBuilder, repository, transactionManager, publicServices: { issuedInvoiceServices }, @@ -138,6 +141,7 @@ export function buildIssueProformaUseCase(deps: { finder, issuer, issueReadModelAssembler, + issueResponseSnapshotBuilder, repository, transactionManager, }); diff --git a/modules/customer-invoices/src/api/application/proformas/index.ts b/modules/customer-invoices/src/api/application/proformas/index.ts index 1e581472..8c1ef6b0 100644 --- a/modules/customer-invoices/src/api/application/proformas/index.ts +++ b/modules/customer-invoices/src/api/application/proformas/index.ts @@ -1,3 +1,4 @@ +export * from "./commands"; export * from "./di"; export * from "./errors"; export * from "./mappers"; diff --git a/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts b/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts index 336ea3da..e8fadfd1 100644 --- a/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts +++ b/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts @@ -15,7 +15,6 @@ import { Maybe, NumberHelper, Result } from "@repo/rdx-utils"; import type { CreateProformaRequestDTO } from "../../../../common"; import { - InvoiceNumber, type InvoiceRecipient, InvoiceSerie, InvoiceStatus, @@ -59,12 +58,6 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper { errors ); - const invoiceNumber = extractOrPushError( - InvoiceNumber.create(dto.invoice_number), - "invoice_number", - errors - ); - const series = extractOrPushError( maybeFromNullableResult(dto.series, (value) => InvoiceSerie.create(value)), "series", diff --git a/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-issue-read-model.assembler.ts b/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-issue-read-model.assembler.ts index ac4ea132..5833cb8b 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-issue-read-model.assembler.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-issue-read-model.assembler.ts @@ -1,4 +1,4 @@ -import type { IPaymentMethodPublicFinder } from "@erp/catalogs/api"; +import type { IPaymentMethodPublicFinder, ITaxRegimePublicFinder } from "@erp/catalogs/api"; import type { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; @@ -23,6 +23,7 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA public constructor( private readonly deps: { paymentMethodFinder: IPaymentMethodPublicFinder; + taxRegimeFinder: ITaxRegimePublicFinder; } ) {} @@ -47,6 +48,12 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA return Result.fail(paymentMethodResult.error); } + const taxRegimeResult = await this.resolveTaxRegime(params); + + if (taxRegimeResult.isFailure) { + return Result.fail(taxRegimeResult.error); + } + const paymentMethod = paymentMethodResult.data; return Result.ok({ @@ -56,6 +63,43 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA name: paymentMethod.name, description: paymentMethod.description, }, + taxRegime: taxRegimeResult.data, + }); + } + + private async resolveTaxRegime(params: { + companyId: UniqueID; + proforma: Proforma; + transaction?: unknown; + }) { + if (params.proforma.taxRegimeCode.isNone()) { + return Result.fail(new Error("Tax regime is required to issue proforma")); + } + + const taxRegimeCode = params.proforma.taxRegimeCode.unwrap(); + + const taxRegimeResult = await this.deps.taxRegimeFinder.findByCodeInCompany({ + companyId: params.companyId, + code: taxRegimeCode, + transaction: params.transaction, + }); + + if (taxRegimeResult.isFailure) { + return Result.fail(taxRegimeResult.error); + } + + if (taxRegimeResult.data.isNone()) { + return Result.ok({ + code: taxRegimeCode, + description: "", + }); + } + + const taxRegime = taxRegimeResult.data.unwrap(); + + return Result.ok({ + code: taxRegime.code, + description: taxRegime.description, }); } } diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/index.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/index.ts index a23ae1ee..5a9529ba 100644 --- a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/index.ts +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/index.ts @@ -1,3 +1,4 @@ export * from "./full"; +export * from "./issue"; export * from "./report"; export * from "./summary"; diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/issue/index.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/issue/index.ts new file mode 100644 index 00000000..d2e8bc0b --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/issue/index.ts @@ -0,0 +1 @@ +export * from "./issue-proforma-response.snapshot-builder"; diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/issue/issue-proforma-response.snapshot-builder.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/issue/issue-proforma-response.snapshot-builder.ts new file mode 100644 index 00000000..4a395cb8 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/issue/issue-proforma-response.snapshot-builder.ts @@ -0,0 +1,39 @@ +import type { ISnapshotBuilder } from "@erp/core/api"; + +import type { IssueProformaByIdResponseDTO } from "../../../../../common"; +import type { IssuedInvoice } from "../../../../domain"; + +export interface IIssueProformaResponseSnapshotBuilder + extends ISnapshotBuilder {} + +export class IssueProformaResponseSnapshotBuilder + implements IIssueProformaResponseSnapshotBuilder +{ + toOutput(invoice: IssuedInvoice): IssueProformaByIdResponseDTO { + return { + issued_invoice: { + id: invoice.id.toString(), + proforma_id: invoice.linkedProformaId.toString(), + series: invoice.series.toString(), + invoice_number: invoice.invoiceNumber.toString(), + invoice_date: invoice.invoiceDate.toDateString(), + status: invoice.status.toPrimitive(), + recipient: { + name: invoice.recipient.name.toString(), + tin: invoice.recipient.tin.toString(), + }, + tax_regime: { + code: invoice.taxRegime.code, + description: invoice.taxRegime.description, + }, + totals: { + taxable_base: invoice.taxableAmount.toObjectString(), + tax_total: invoice.taxesAmount.toObjectString(), + retention_total: invoice.retentionAmount.toObjectString(), + grand_total: invoice.totalAmount.toObjectString(), + currency_code: invoice.currencyCode.toPrimitive(), + }, + }, + }; + } +} diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.ts index 12deab54..2952c67f 100644 --- a/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.ts +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.ts @@ -3,17 +3,14 @@ import { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import type { IIssuedInvoicePublicServices } from "../../issued-invoices"; +import type { IssueProformaCommand } from "../commands"; import type { IProformaRepository } from "../repositories"; import type { IProformaFinder, IProformaIssueReadModelAssembler, IProformaIssuer, } from "../services"; - -type IssueProformaUseCaseInput = { - companyId: UniqueID; - proforma_id: string; -}; +import type { IIssueProformaResponseSnapshotBuilder } from "../snapshot-builders"; /** * Caso de uso: conversión de una proforma en factura definitiva. @@ -31,15 +28,22 @@ export class IssueProformaUseCase { finder: IProformaFinder; issuer: IProformaIssuer; issueReadModelAssembler: IProformaIssueReadModelAssembler; + issueResponseSnapshotBuilder: IIssueProformaResponseSnapshotBuilder; repository: IProformaRepository; transactionManager: ITransactionManager; } ) {} - public execute(params: IssueProformaUseCaseInput) { - const { proforma_id, companyId } = params; + public execute(command: IssueProformaCommand) { + const companyIdResult = UniqueID.create(command.companyId); - const proformaIdResult = UniqueID.create(proforma_id); + if (companyIdResult.isFailure) { + return Result.fail(companyIdResult.error); + } + + const companyId = companyIdResult.data; + + const proformaIdResult = UniqueID.create(command.proformaId); if (proformaIdResult.isFailure) { return Result.fail(proformaIdResult.error); @@ -106,11 +110,7 @@ export class IssueProformaUseCase { return Result.fail(markAsIssuedResult.error); } - return Result.ok({ - issuedinvoice_id: issuedInvoiceId.toString(), - proforma_id: proformaId.toString(), - customer_id: proforma.customerId.toString(), - }); + return Result.ok(this.deps.issueResponseSnapshotBuilder.toOutput(invoiceResult.data)); } catch (error: unknown) { return Result.fail(error as Error); } diff --git a/modules/customer-invoices/src/api/domain/issued-invoices/aggregates/issued-invoice.aggregate.ts b/modules/customer-invoices/src/api/domain/issued-invoices/aggregates/issued-invoice.aggregate.ts index 5f97614d..1239ded9 100644 --- a/modules/customer-invoices/src/api/domain/issued-invoices/aggregates/issued-invoice.aggregate.ts +++ b/modules/customer-invoices/src/api/domain/issued-invoices/aggregates/issued-invoice.aggregate.ts @@ -17,6 +17,7 @@ import type { InvoicePaymentMethod, InvoiceSerie, InvoiceStatus, + InvoiceTaxRegime, } from "../../common"; import { type IIssuedInvoiceItemCreateProps, @@ -51,6 +52,7 @@ export interface IIssuedInvoiceCreateProps { currencyCode: CurrencyCode; paymentMethod: InvoicePaymentMethod; + taxRegime: InvoiceTaxRegime; items: IIssuedInvoiceItemCreateProps[]; taxes: IssuedInvoiceTaxes; @@ -139,6 +141,16 @@ export class IssuedInvoice ); } + if (!props.taxRegime) { + return Result.fail( + new DomainValidationError( + "MISSING_TAX_REGIME", + "taxRegime", + "Issued invoice requires tax regime" + ) + ); + } + return Result.ok(); } @@ -222,6 +234,10 @@ export class IssuedInvoice return this.props.paymentMethod; } + public get taxRegime(): InvoiceTaxRegime { + return this.props.taxRegime; + } + public get languageCode(): LanguageCode { return this.props.languageCode; } diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-domain.mapper.ts index 5c930240..9b5b076b 100644 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-domain.mapper.ts @@ -1,6 +1,7 @@ import { DiscountPercentage, type MapperParamsType, SequelizeDomainMapper } from "@erp/core/api"; import { CurrencyCode, + DomainValidationError, LanguageCode, TextValue, UniqueID, @@ -20,6 +21,7 @@ import { InvoicePaymentMethod, InvoiceSerie, InvoiceStatus, + InvoiceTaxRegime, IssuedInvoice, IssuedInvoiceItems, IssuedInvoiceTaxes, @@ -154,6 +156,23 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper< } } + const taxRegime = extractOrPushError( + !isNullishOrEmpty(raw.tax_regime_code) + ? InvoiceTaxRegime.create({ + code: String(raw.tax_regime_code), + description: String(raw.tax_regime_description ?? raw.tax_regime_code), + }) + : Result.fail( + new DomainValidationError( + "MISSING_TAX_REGIME", + "tax_regime", + "Issued invoice requires persisted tax regime" + ) + ), + "tax_regime", + errors + ); + const subtotalAmount = extractOrPushError( InvoiceAmount.create({ value: raw.subtotal_amount_value, @@ -270,6 +289,7 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper< languageCode, currencyCode, paymentMethod, + taxRegime, subtotalAmount, itemsDiscountAmount, @@ -384,6 +404,7 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper< totalAmount: attributes.totalAmount!, paymentMethod: attributes.paymentMethod!, + taxRegime: attributes.taxRegime!, taxes, verifactu, @@ -483,6 +504,8 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper< payment_method_id: source.paymentMethod.toObjectString().id, payment_method_description: source.paymentMethod.toObjectString().name, + tax_regime_code: source.taxRegime.code, + tax_regime_description: source.taxRegime.description, subtotal_amount_value: source.subtotalAmount.value, subtotal_amount_scale: source.subtotalAmount.scale, diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts index 1db41cdb..0a6b449d 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts @@ -117,6 +117,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter const issueReadModelAssembler = buildProformaIssueReadModelAssembler({ paymentMethodFinder: catalogs.paymentMethod.finder, + taxRegimeFinder: catalogs.taxRegime.finder, }); const companyReportProfileFinder = new CompanyReportProfileFinder({ @@ -188,6 +189,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter finder, issuer, issueReadModelAssembler, + issueResponseSnapshotBuilder: snapshotBuilders.issue, repository, transactionManager, }), diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/issue-proforma.controller.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/issue-proforma.controller.ts index fffca478..e9c40c33 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/issue-proforma.controller.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/issue-proforma.controller.ts @@ -5,11 +5,16 @@ import { requireCompanyContextGuard, } from "@erp/core/api"; +import { IssueProformaByIdResponseSchema } from "../../../../../common"; import type { IssueProformaUseCase } from "../../../../application/proformas"; +import type { IIssueProformaByIdRequestMapper } from "../mappers"; import { proformasApiErrorMapper } from "../proformas-api-error-mapper.ts"; export class IssueProformaController extends ExpressController { - public constructor(private readonly useCase: IssueProformaUseCase) { + public constructor( + private readonly useCase: IssueProformaUseCase, + private readonly requestMapper: IIssueProformaByIdRequestMapper + ) { super(); this.errorMapper = proformasApiErrorMapper; @@ -27,15 +32,26 @@ export class IssueProformaController extends ExpressController { return this.forbiddenError("Tenant ID not found"); } - const { proforma_id } = this.req.params; - if (!proforma_id) { + const params = this.req.params; + const proformaId = params.id ?? params.proforma_id; + + if (!proformaId) { return this.invalidInputError("Proforma ID missing"); } - const result = await this.useCase.execute({ proforma_id, companyId }); + const command = this.requestMapper.map({ + companyId: companyId.toString(), + params: params as + | { id: string } + | { + proforma_id: string; + }, + }); + + const result = await this.useCase.execute(command); return result.match( - (data) => this.ok(data), + (data) => this.ok(IssueProformaByIdResponseSchema.parse(data)), (err) => this.handleError(err) ); } diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/index.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/index.ts index 7ae6a44c..c928b3d2 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/express/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/index.ts @@ -1,2 +1,3 @@ export * from "./controllers"; +export * from "./mappers"; export * from "./proformas.routes"; diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/mappers/index.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/mappers/index.ts new file mode 100644 index 00000000..238e2f7f --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/mappers/index.ts @@ -0,0 +1 @@ +export * from "./issue-proforma-by-id-request.mapper"; diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/mappers/issue-proforma-by-id-request.mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/mappers/issue-proforma-by-id-request.mapper.ts new file mode 100644 index 00000000..3efbd7f3 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/mappers/issue-proforma-by-id-request.mapper.ts @@ -0,0 +1,27 @@ +import type { + IssueProformaByIdParamsRequestDTO, + LegacyIssueProformaByIdParamsRequestDTO, +} from "../../../../../common"; +import type { IssueProformaCommand } from "../../../../application/proformas"; + +type IssueProformaRequestMapperInput = { + companyId: string; + params: IssueProformaByIdParamsRequestDTO | LegacyIssueProformaByIdParamsRequestDTO; + actorUserId?: string; +}; + +export interface IIssueProformaByIdRequestMapper { + map(params: IssueProformaRequestMapperInput): IssueProformaCommand; +} + +export class IssueProformaByIdRequestMapper implements IIssueProformaByIdRequestMapper { + map(params: IssueProformaRequestMapperInput): IssueProformaCommand { + const proformaId = "id" in params.params ? params.params.id : params.params.proforma_id; + + return { + companyId: params.companyId, + proformaId, + actorUserId: params.actorUserId, + }; + } +} diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts index 41516a5b..83457cc1 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts @@ -15,6 +15,7 @@ import { ChangeStatusProformaByIdParamsRequestSchema, ChangeStatusProformaByIdRequestSchema, CreateProformaRequestSchema, + LegacyIssueProformaByIdParamsRequestSchema, GetProformaByIdRequestSchema, IssueProformaByIdParamsRequestSchema, ListProformasRequestSchema, @@ -27,6 +28,7 @@ import type { IIssuedInvoicePublicServices } from "../../../application"; import { ChangeStatusProformaController } from "./controllers/change-status-proforma.controller"; import { CreateProformaController } from "./controllers/create-proforma.controller"; +import { IssueProformaByIdRequestMapper } from "./mappers"; import { UpdateProformaController } from "./controllers/update-proforma.controller"; export const proformasRouter = (params: StartParams) => { @@ -141,17 +143,29 @@ export const proformasRouter = (params: StartParams) => { } ); + const issueProformaHandler = (req: Request, res: Response, next: NextFunction) => { + const useCase = deps.useCases.issueProforma(publicServices); + const requestMapper = new IssueProformaByIdRequestMapper(); + const controller = new IssueProformaController(useCase, requestMapper); + return controller.execute(req, res, next); + }; + + router.post( + "/:id/issue", + validateRequest(IssueProformaByIdParamsRequestSchema, "params"), + issueProformaHandler + ); + + router.post( + "/:proforma_id/issue", + validateRequest(LegacyIssueProformaByIdParamsRequestSchema, "params"), + issueProformaHandler + ); + router.put( "/:proforma_id/issue", - //checkTabContext, - - validateRequest(IssueProformaByIdParamsRequestSchema, "params"), - - (req: Request, res: Response, next: NextFunction) => { - const useCase = deps.useCases.issueProforma(publicServices); - const controller = new IssueProformaController(useCase); - return controller.execute(req, res, next); - } + validateRequest(LegacyIssueProformaByIdParamsRequestSchema, "params"), + issueProformaHandler ); app.use(`${config.server.apiBasePath}/proformas`, router); diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/services/sequelize-proforma-number-generator.service.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/services/sequelize-proforma-number-generator.service.ts index 509000e6..0f751219 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/services/sequelize-proforma-number-generator.service.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/services/sequelize-proforma-number-generator.service.ts @@ -1,6 +1,6 @@ import type { UniqueID } from "@repo/rdx-ddd"; import { type Maybe, Result } from "@repo/rdx-utils"; -import { type Transaction, type WhereOptions, literal } from "sequelize"; +import { type Transaction, type WhereOptions } from "sequelize"; import type { IProformaNumberGenerator } from "../../../../../application/proformas"; import { InvoiceNumber, type InvoiceSerie } from "../../../../../domain"; @@ -11,6 +11,9 @@ import { CustomerInvoiceModel } from "../../../../common/persistence"; * La serie elegida en la proforma solo referencia la futura InvoiceSeries. */ export class SequelizeProformaNumberGenerator implements IProformaNumberGenerator { + private static readonly PREFIX = "PF-"; + private static readonly DIGITS = 4; + public async getNextForCompany( companyId: UniqueID, _series: Maybe, @@ -22,24 +25,20 @@ export class SequelizeProformaNumberGenerator implements IProformaNumberGenerato }; try { - const lastInvoice = await CustomerInvoiceModel.findOne({ + const existingInvoices = await CustomerInvoiceModel.findAll({ attributes: ["invoice_number"], where, - // Orden numérico real: CAST(... AS UNSIGNED) - order: [literal("CAST(invoice_number AS UNSIGNED) DESC")], transaction, raw: true, - // Bloqueo opcional para evitar carreras si estás dentro de una TX - lock: transaction.LOCK.UPDATE, // requiere InnoDB y TX abierta + lock: transaction.LOCK.UPDATE, }); - let nextValue = "0001"; // valor inicial por defecto + const maxSequence = existingInvoices.reduce((max, currentInvoice) => { + const currentSequence = this.extractSequence(currentInvoice.invoice_number); + return Math.max(max, currentSequence); + }, 0); - if (lastInvoice) { - const current = Number(lastInvoice.invoice_number); - const next = Number.isFinite(current) && current > 0 ? current + 1 : 1; - nextValue = String(next).padStart(4, "0"); - } + const nextValue = this.formatSequence(maxSequence + 1); const numberResult = InvoiceNumber.create(nextValue); if (numberResult.isFailure) { @@ -50,9 +49,27 @@ export class SequelizeProformaNumberGenerator implements IProformaNumberGenerato } catch (error) { return Result.fail( new Error( - `Error generating invoice number for company ${companyId}: ${(error as Error).message}` + `Error generating proforma number for company ${companyId}: ${(error as Error).message}` ) ); } } + + private extractSequence(invoiceNumber: string): number { + const normalized = invoiceNumber.trim().toUpperCase(); + + if (normalized.startsWith(SequelizeProformaNumberGenerator.PREFIX)) { + const suffix = normalized.slice(SequelizeProformaNumberGenerator.PREFIX.length); + const parsed = Number(suffix); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; + } + + const parsed = Number(normalized); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; + } + + private formatSequence(sequence: number): string { + const padded = String(sequence).padStart(SequelizeProformaNumberGenerator.DIGITS, "0"); + return `${SequelizeProformaNumberGenerator.PREFIX}${padded}`; + } } diff --git a/modules/customer-invoices/src/common/dto/request/proformas/create-proforma.request.dto.ts b/modules/customer-invoices/src/common/dto/request/proformas/create-proforma.request.dto.ts index c202dda3..6421cf63 100644 --- a/modules/customer-invoices/src/common/dto/request/proformas/create-proforma.request.dto.ts +++ b/modules/customer-invoices/src/common/dto/request/proformas/create-proforma.request.dto.ts @@ -28,7 +28,7 @@ export type CreateProformaItemRequestDTO = z.infer; + +export const LegacyIssueProformaByIdParamsRequestSchema = z.object({ + proforma_id: z.string(), +}); + +export type LegacyIssueProformaByIdParamsRequestDTO = z.infer< + typeof LegacyIssueProformaByIdParamsRequestSchema +>; diff --git a/modules/customer-invoices/src/common/dto/response/issued-invoices/get-issued-invoice-by-id.response.dto.ts b/modules/customer-invoices/src/common/dto/response/issued-invoices/get-issued-invoice-by-id.response.dto.ts index 7d98278a..fce79656 100644 --- a/modules/customer-invoices/src/common/dto/response/issued-invoices/get-issued-invoice-by-id.response.dto.ts +++ b/modules/customer-invoices/src/common/dto/response/issued-invoices/get-issued-invoice-by-id.response.dto.ts @@ -46,7 +46,7 @@ export const GetIssuedInvoiceByIdResponseSchema = z.object({ payment_method: PaymentMethodRefSchema.nullable(), - tax_regime: TaxRegimeRefSchema.nullable(), + tax_regime: TaxRegimeRefSchema, subtotal_amount: MoneySchema, diff --git a/modules/customer-invoices/src/common/dto/response/proformas/issue-proforma-by-id.response.dto.ts b/modules/customer-invoices/src/common/dto/response/proformas/issue-proforma-by-id.response.dto.ts index 40ececdb..5672821d 100644 --- a/modules/customer-invoices/src/common/dto/response/proformas/issue-proforma-by-id.response.dto.ts +++ b/modules/customer-invoices/src/common/dto/response/proformas/issue-proforma-by-id.response.dto.ts @@ -1,9 +1,35 @@ +import { CurrencyCodeSchema, IsoDateSchema, MoneySchema } from "@erp/core"; import { z } from "zod/v4"; +import { IssuedInvoiceStatusSchema, TaxRegimeRefSchema } from "../../shared"; + +export const IssueProformaIssuedInvoiceRecipientSchema = z.object({ + name: z.string(), + tin: z.string().nullable(), +}); + +export const IssueProformaIssuedInvoiceTotalsSchema = z.object({ + taxable_base: MoneySchema, + tax_total: MoneySchema, + retention_total: MoneySchema, + grand_total: MoneySchema, + currency_code: CurrencyCodeSchema, +}); + +export const IssueProformaIssuedInvoiceSchema = z.object({ + id: z.uuid(), + proforma_id: z.uuid().nullable(), + series: z.string(), + invoice_number: z.string(), + invoice_date: IsoDateSchema, + status: IssuedInvoiceStatusSchema, + recipient: IssueProformaIssuedInvoiceRecipientSchema, + tax_regime: TaxRegimeRefSchema, + totals: IssueProformaIssuedInvoiceTotalsSchema, +}); + export const IssueProformaByIdResponseSchema = z.object({ - issuedinvoice_id: z.string(), - proforma_id: z.string(), - customer_id: z.string(), + issued_invoice: IssueProformaIssuedInvoiceSchema, }); export type IssueProformaByIdResponseDTO = z.infer; diff --git a/modules/customer-invoices/src/common/dto/shared/tax-regime-ref.dto.ts b/modules/customer-invoices/src/common/dto/shared/tax-regime-ref.dto.ts index f9d3b444..d0d8c79d 100644 --- a/modules/customer-invoices/src/common/dto/shared/tax-regime-ref.dto.ts +++ b/modules/customer-invoices/src/common/dto/shared/tax-regime-ref.dto.ts @@ -2,7 +2,7 @@ import { z } from "zod/v4"; export const TaxRegimeRefSchema = z.object({ code: z.string(), - description: z.string(), + description: z.string().nullable(), }); export type TaxRegimeRefDTO = z.infer;