diff --git a/docs/customer-invoices/sql/dev-split-proformas-issued-invoices.sql b/docs/customer-invoices/sql/dev-split-proformas-issued-invoices.sql index e3dfea14..dc24a977 100644 --- a/docs/customer-invoices/sql/dev-split-proformas-issued-invoices.sql +++ b/docs/customer-invoices/sql/dev-split-proformas-issued-invoices.sql @@ -113,16 +113,25 @@ CREATE TABLE IF NOT EXISTS proforma_items ( taxable_amount_value BIGINT NOT NULL DEFAULT 0, taxable_amount_scale SMALLINT NOT NULL DEFAULT 4, iva_code VARCHAR(40) NULL, + iva_name VARCHAR(100) NULL, + iva_group VARCHAR(40) NULL, + iva_calculation_behavior VARCHAR(40) NULL, iva_percentage_value SMALLINT NULL, iva_percentage_scale SMALLINT NOT NULL DEFAULT 2, iva_amount_value BIGINT NOT NULL DEFAULT 0, iva_amount_scale SMALLINT NOT NULL DEFAULT 4, rec_code VARCHAR(40) NULL, + rec_name VARCHAR(100) NULL, + rec_group VARCHAR(40) NULL, + rec_calculation_behavior VARCHAR(40) NULL, rec_percentage_value SMALLINT NULL, rec_percentage_scale SMALLINT NOT NULL DEFAULT 2, rec_amount_value BIGINT NOT NULL DEFAULT 0, rec_amount_scale SMALLINT NOT NULL DEFAULT 4, retention_code VARCHAR(40) NULL, + retention_name VARCHAR(100) NULL, + retention_group VARCHAR(40) NULL, + retention_calculation_behavior VARCHAR(40) NULL, retention_percentage_value SMALLINT NULL, retention_percentage_scale SMALLINT NOT NULL DEFAULT 2, retention_amount_value BIGINT NOT NULL DEFAULT 0, @@ -159,7 +168,6 @@ CREATE TABLE IF NOT EXISTS proforma_taxes ( taxes_amount_value BIGINT NOT NULL DEFAULT 0, taxes_amount_scale SMALLINT NOT NULL DEFAULT 4, PRIMARY KEY (tax_id), - UNIQUE KEY proforma_iva_code_unique (proforma_id, iva_code), KEY proforma_id_idx (proforma_id) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; @@ -260,16 +268,19 @@ CREATE TABLE IF NOT EXISTS issued_invoice_items ( taxable_amount_value BIGINT NOT NULL DEFAULT 0, taxable_amount_scale SMALLINT NOT NULL DEFAULT 4, iva_code VARCHAR(40) NULL, + iva_name VARCHAR(100) NULL, iva_percentage_value SMALLINT NULL, iva_percentage_scale SMALLINT NOT NULL DEFAULT 2, iva_amount_value BIGINT NOT NULL DEFAULT 0, iva_amount_scale SMALLINT NOT NULL DEFAULT 4, rec_code VARCHAR(40) NULL, + rec_name VARCHAR(100) NULL, rec_percentage_value SMALLINT NULL, rec_percentage_scale SMALLINT NOT NULL DEFAULT 2, rec_amount_value BIGINT NOT NULL DEFAULT 0, rec_amount_scale SMALLINT NOT NULL DEFAULT 4, retention_code VARCHAR(40) NULL, + retention_name VARCHAR(100) NULL, retention_percentage_value SMALLINT NULL, retention_percentage_scale SMALLINT NOT NULL DEFAULT 2, retention_amount_value BIGINT NOT NULL DEFAULT 0, @@ -289,16 +300,19 @@ CREATE TABLE IF NOT EXISTS issued_invoice_taxes ( taxable_amount_value BIGINT NOT NULL DEFAULT 0, taxable_amount_scale SMALLINT NOT NULL DEFAULT 4, iva_code VARCHAR(40) NULL, + iva_name VARCHAR(100) NULL, iva_percentage_value SMALLINT NULL, iva_percentage_scale SMALLINT NOT NULL DEFAULT 2, iva_amount_value BIGINT NULL, iva_amount_scale SMALLINT NOT NULL DEFAULT 4, rec_code VARCHAR(40) NULL, + rec_name VARCHAR(100) NULL, rec_percentage_value SMALLINT NULL, rec_percentage_scale SMALLINT NOT NULL DEFAULT 2, rec_amount_value BIGINT NULL, rec_amount_scale SMALLINT NOT NULL DEFAULT 4, retention_code VARCHAR(40) NULL, + retention_name VARCHAR(100) NULL, retention_percentage_value SMALLINT NULL, retention_percentage_scale SMALLINT NOT NULL DEFAULT 2, retention_amount_value BIGINT NULL, @@ -306,7 +320,6 @@ CREATE TABLE IF NOT EXISTS issued_invoice_taxes ( taxes_amount_value BIGINT NOT NULL DEFAULT 0, taxes_amount_scale SMALLINT NOT NULL DEFAULT 4, PRIMARY KEY (tax_id), - UNIQUE KEY issued_invoice_iva_code_unique (issued_invoice_id, iva_code), KEY issued_invoice_id_idx (issued_invoice_id) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; diff --git a/modules/catalogs/src/api/application/tax-definitions/public/services/tax-definition-public-finder.interface.ts b/modules/catalogs/src/api/application/tax-definitions/public/services/tax-definition-public-finder.interface.ts index ddecc205..39bf3990 100644 --- a/modules/catalogs/src/api/application/tax-definitions/public/services/tax-definition-public-finder.interface.ts +++ b/modules/catalogs/src/api/application/tax-definitions/public/services/tax-definition-public-finder.interface.ts @@ -17,6 +17,12 @@ export interface FindActiveTaxDefinitionsByCodesParams { transaction?: unknown; } +export interface FindTaxDefinitionsByCodesParams { + companyId: UniqueID; + codes: Collection; + transaction?: unknown; +} + export interface ITaxDefinitionPublicFinder { findActiveByCode( params: FindActiveTaxDefinitionByCodeParams @@ -26,6 +32,10 @@ export interface ITaxDefinitionPublicFinder { params: FindActiveTaxDefinitionsByCodesParams ): Promise, Error>>; + findByCodes( + params: FindTaxDefinitionsByCodesParams + ): Promise, Error>>; + ensureActiveByCodes( params: FindActiveTaxDefinitionsByCodesParams ): Promise, Error>>; diff --git a/modules/catalogs/src/api/application/tax-definitions/public/services/tax-definition-public-finder.ts b/modules/catalogs/src/api/application/tax-definitions/public/services/tax-definition-public-finder.ts index 5c4576d1..649a249a 100644 --- a/modules/catalogs/src/api/application/tax-definitions/public/services/tax-definition-public-finder.ts +++ b/modules/catalogs/src/api/application/tax-definitions/public/services/tax-definition-public-finder.ts @@ -6,6 +6,7 @@ import type { TaxDefinitionPublicModelMapper } from "../mappers/tax-definition-p import type { FindActiveTaxDefinitionByCodeParams, FindActiveTaxDefinitionsByCodesParams, + FindTaxDefinitionsByCodesParams, ITaxDefinitionPublicFinder, } from "./tax-definition-public-finder.interface"; import { TaxDefinitionPublicModel } from '../models'; @@ -38,6 +39,20 @@ export class TaxDefinitionPublicFinder implements ITaxDefinitionPublicFinder { return Result.ok(new Collection(result.data.map((taxDefinition) => this.deps.mapper.toPublicModel(taxDefinition)))); } + public async findByCodes( + params: FindTaxDefinitionsByCodesParams + ): Promise, Error>> { + const result = await this.deps.repository.findByCodesInCompany(params); + + if (result.isFailure) { + return Result.fail(result.error); + } + + return Result.ok( + new Collection(result.data.getAll().map((taxDefinition) => this.deps.mapper.toPublicModel(taxDefinition))) + ); + } + public async ensureActiveByCodes(params: FindActiveTaxDefinitionsByCodesParams):Promise, Error>> { const result = await this.findActiveByCodes(params); diff --git a/modules/catalogs/src/api/application/tax-definitions/repositories/tax-definition-repository.interface.ts b/modules/catalogs/src/api/application/tax-definitions/repositories/tax-definition-repository.interface.ts index c239ab91..029bf5ba 100644 --- a/modules/catalogs/src/api/application/tax-definitions/repositories/tax-definition-repository.interface.ts +++ b/modules/catalogs/src/api/application/tax-definitions/repositories/tax-definition-repository.interface.ts @@ -46,6 +46,12 @@ export interface ITaxDefinitionRepository { transaction?: unknown; }): Promise, Error>>; + findByCodesInCompany(params: { + companyId: UniqueID; + codes: Collection; + transaction?: unknown; + }): Promise, Error>>; + findByCriteriaInCompany( companyId: UniqueID, criteria: Criteria, diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/repositories/sequelize-tax-definition.repository.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/repositories/sequelize-tax-definition.repository.ts index 7d900f29..f10638e6 100644 --- a/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/repositories/sequelize-tax-definition.repository.ts +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/repositories/sequelize-tax-definition.repository.ts @@ -179,6 +179,34 @@ export class SequelizeTaxDefinitionRepository } } + async findByCodesInCompany(params: { + companyId: UniqueID; + codes: Collection; + transaction?: Transaction; + }): Promise, Error>> { + try { + const normalizedCodes = Array.from( + new Set(params.codes.getAll().map((code) => this.normalizeCode(code))) + ); + + if (normalizedCodes.length === 0) { + return Result.ok(new Collection([])); + } + + const rows = await TaxDefinitionModel.findAll({ + where: { + company_id: params.companyId.toString(), + code: { [Op.in]: normalizedCodes }, + }, + transaction: params.transaction, + }); + + return this.domainMapper.mapToDomainCollection(rows, rows.length); + } catch (err: unknown) { + return Result.fail(translateSequelizeError(err)); + } + } + async findByCriteriaInCompany( companyId: UniqueID, criteria: Criteria, diff --git a/modules/core/src/api/application/documents/services/document-metadata-factory.interface.ts b/modules/core/src/api/application/documents/services/document-metadata-factory.interface.ts index 846dede0..5bd44a34 100644 --- a/modules/core/src/api/application/documents/services/document-metadata-factory.interface.ts +++ b/modules/core/src/api/application/documents/services/document-metadata-factory.interface.ts @@ -1,5 +1,5 @@ import type { IDocumentMetadata } from "../application-models"; -export interface IDocumentMetadataFactory { - build(snapshot: TSnapshot): IDocumentMetadata; +export interface IDocumentMetadataFactory> { + build(snapshot: TSnapshot, context: TSnapshotContext): IDocumentMetadata; } diff --git a/modules/customer-invoices/src/api/application/issued-invoices/audit/issued-invoice-audit.test.ts b/modules/customer-invoices/src/api/application/issued-invoices/audit/issued-invoice-audit.test.ts index 41c26438..88358fd7 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/audit/issued-invoice-audit.test.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/audit/issued-invoice-audit.test.ts @@ -66,12 +66,15 @@ const createIssuedInvoiceFixture = (withOptionals = true): IssuedInvoice => { totalDiscountAmount: amount(643), taxableAmount: amount(3107), ivaCode: Maybe.some("iva_21"), + ivaName: Maybe.some("IVA general"), ivaPercentage: Maybe.some(iva), ivaAmount: amount(652), recCode: Maybe.some("rec_52"), + recName: Maybe.some("Recargo de equivalencia"), recPercentage: Maybe.some(rec), recAmount: amount(162), retentionCode: Maybe.some("ret_15"), + retentionName: Maybe.some("Retención IRPF"), retentionPercentage: Maybe.some(retention), retentionAmount: amount(466), taxesAmount: amount(348), @@ -105,12 +108,15 @@ const createIssuedInvoiceFixture = (withOptionals = true): IssuedInvoice => { IssuedInvoiceTax.create({ taxableAmount: total(6214), ivaCode: "iva_21", + ivaName: "IVA general", ivaPercentage: iva, ivaAmount: total(1305), recCode: Maybe.some("rec_52"), + recName: Maybe.some("Recargo de equivalencia"), recPercentage: Maybe.some(rec), recAmount: total(323), retentionCode: Maybe.some("ret_15"), + retentionName: Maybe.some("Retención IRPF"), retentionPercentage: Maybe.some(retention), retentionAmount: total(932), taxesAmount: total(696), diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.test.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.test.ts index 09ea8034..8c248ffd 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.test.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.test.ts @@ -1,10 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { DiscountPercentage } from "@erp/core/api"; +import { DiscountPercentage, TaxPercentage } from "@erp/core/api"; import { CurrencyCode, LanguageCode, UniqueID, UtcDate } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; +import { InvoiceAmount } from "../../../domain"; import { ProformaToIssuedInvoiceConverter } from "./proforma-to-issued-invoice-props-converter"; test("toCreateProps no materializa VerifactuRecord ni policy en el converter", () => { @@ -155,3 +156,27 @@ test("resolvePaymentTerm convierte el DTO de porcentaje al formato de dominio", }, ]); }); + +test("resolveTax copia los nombres preservados por Proforma.taxes sin reconstruirlos", () => { + const converter = new ProformaToIssuedInvoiceConverter(); + const percentage = TaxPercentage.create({ value: 2200 }).data; + const amount = InvoiceAmount.create({ value: 2200, currency_code: "EUR" }).data; + const taxableAmount = InvoiceAmount.create({ value: 10000, currency_code: "EUR" }).data; + + const result = (converter as unknown as { + resolveTax: (tax: unknown) => Result<{ ivaName: string; ivaCode: string }, Error>; + }).resolveTax({ + taxableAmount, + ivaCode: Maybe.some("iva_21"), + ivaName: Maybe.some("IVA actualizado"), + ivaPercentage: Maybe.some(percentage), + ivaAmount: amount, + recCode: Maybe.none(), recName: Maybe.none(), recPercentage: Maybe.none(), recAmount: amount, + retentionCode: Maybe.none(), retentionName: Maybe.none(), retentionPercentage: Maybe.none(), retentionAmount: amount, + taxesAmount: amount, + }); + + assert.equal(result.isSuccess, true); + assert.equal(result.data.ivaName, "IVA actualizado"); + assert.notEqual(result.data.ivaName, result.data.ivaCode); +}); 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 b64600e9..b390d95f 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 @@ -208,14 +208,17 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic totalDiscountAmount: itemTotals.totalDiscountAmount, ivaCode: item.ivaCode(), + ivaName: item.taxes.iva.map((tax) => tax.name), ivaPercentage: item.ivaPercentage(), ivaAmount: itemTotals.ivaAmount, recCode: item.recCode(), + recName: item.taxes.rec.map((tax) => tax.name), recPercentage: item.recPercentage(), recAmount: itemTotals.recAmount, retentionCode: item.retentionCode(), + retentionName: item.taxes.retention.map((tax) => tax.name), retentionPercentage: item.retentionPercentage(), retentionAmount: itemTotals.retentionAmount, @@ -245,21 +248,24 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic private resolveTax( tax: ReturnType["getAll"]>[number] ): Result { - if (tax.ivaCode.isNone()) { - return Result.fail(new Error("IVA code is required")); + if (tax.ivaCode.isNone() || tax.ivaName.isNone()) { + return Result.fail(new Error("IVA code and name are required")); } return IssuedInvoiceTax.create({ taxableAmount: tax.taxableAmount, ivaCode: tax.ivaCode.unwrap(), + ivaName: tax.ivaName.unwrap(), ivaPercentage: tax.ivaPercentage.unwrap(), ivaAmount: tax.ivaAmount, recCode: tax.recCode, + recName: tax.recName, recPercentage: tax.recPercentage, recAmount: tax.recAmount, retentionCode: tax.retentionCode, + retentionName: tax.retentionName, retentionPercentage: tax.retentionPercentage, retentionAmount: tax.retentionAmount, diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-item-full-snapshot.interface.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-item-full-snapshot.interface.ts index 0adf5378..77e7e965 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-item-full-snapshot.interface.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-item-full-snapshot.interface.ts @@ -20,14 +20,17 @@ export interface IIssuedInvoiceItemFullSnapshot { taxable_amount: { value: string; scale: string; currency_code: string }; iva_code: string; + iva_name: string; iva_percentage: { value: string; scale: string }; iva_amount: { value: string; scale: string; currency_code: string }; rec_code: string; + rec_name: string; rec_percentage: { value: string; scale: string }; rec_amount: { value: string; scale: string; currency_code: string }; retention_code: string; + retention_name: string; retention_percentage: { value: string; scale: string }; retention_amount: { value: string; scale: string; currency_code: string }; diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-items-full-snapshot-builder.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-items-full-snapshot-builder.ts index 04ebc6c8..9af329c1 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-items-full-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-items-full-snapshot-builder.ts @@ -68,14 +68,17 @@ export class IssuedInvoiceItemsFullSnapshotBuilder : emptyMoneyObject, iva_code: maybeToEmptyString(invoiceItem.ivaCode), + iva_name: maybeToEmptyString(invoiceItem.ivaName), iva_percentage: maybeToEmptyPercentageObjectString(invoiceItem.ivaPercentage), iva_amount: isValued ? invoiceItem.ivaAmount.toObjectString() : emptyMoneyObject, rec_code: maybeToEmptyString(invoiceItem.recCode), + rec_name: maybeToEmptyString(invoiceItem.recName), rec_percentage: maybeToEmptyPercentageObjectString(invoiceItem.recPercentage), rec_amount: isValued ? invoiceItem.recAmount.toObjectString() : emptyMoneyObject, retention_code: maybeToEmptyString(invoiceItem.retentionCode), + retention_name: maybeToEmptyString(invoiceItem.retentionName), retention_percentage: maybeToEmptyPercentageObjectString(invoiceItem.retentionPercentage), retention_amount: isValued ? invoiceItem.retentionAmount.toObjectString() diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-tax-full-snapshot-interface.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-tax-full-snapshot-interface.ts index bcde770f..2fb5a7d3 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-tax-full-snapshot-interface.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-tax-full-snapshot-interface.ts @@ -2,14 +2,17 @@ export interface IIssuedInvoiceTaxFullSnapshot { taxable_amount: { value: string; scale: string; currency_code: string }; iva_code: string; + iva_name: string; iva_percentage: { value: string; scale: string }; iva_amount: { value: string; scale: string; currency_code: string }; rec_code: string | null; + rec_name: string | null; rec_percentage: { value: string; scale: string }; rec_amount: { value: string; scale: string; currency_code: string }; retention_code: string | null; + retention_name: string | null; retention_percentage: { value: string; scale: string }; retention_amount: { value: string; scale: string; currency_code: string }; diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-taxes-full-snapshot-builder.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-taxes-full-snapshot-builder.ts index 065c0163..88066168 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-taxes-full-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-taxes-full-snapshot-builder.ts @@ -16,14 +16,17 @@ export class IssuedInvoiceTaxesFullSnapshotBuilder taxable_amount: invoiceTax.taxableAmount.toObjectString(), iva_code: invoiceTax.ivaCode.toString(), + iva_name: invoiceTax.ivaName, iva_percentage: invoiceTax.ivaPercentage.toObjectString(), iva_amount: invoiceTax.ivaAmount.toObjectString(), rec_code: maybeToNullable(invoiceTax.recCode, (v) => v.toString()), + rec_name: maybeToNullable(invoiceTax.recName, (v) => v.toString()), rec_percentage: maybeToEmptyPercentageObjectString(invoiceTax.recPercentage), rec_amount: invoiceTax.recAmount.toObjectString(), retention_code: maybeToNullable(invoiceTax.retentionCode, (v) => v.toString()), + retention_name: maybeToNullable(invoiceTax.retentionName, (v) => v.toString()), retention_percentage: maybeToEmptyPercentageObjectString(invoiceTax.retentionPercentage), retention_amount: invoiceTax.retentionAmount.toObjectString(), diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-tax-snapshot-builder.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-tax-snapshot-builder.ts index 4ea68d6d..e3d9ee8e 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-tax-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-tax-snapshot-builder.ts @@ -26,14 +26,17 @@ export class IssuedInvoiceReportTaxSnapshotBuilder taxable_amount: MoneyDTOHelper.format(tax.taxable_amount, locale, moneyOptions), iva_code: tax.iva_code, + iva_name: tax.iva_name, iva_percentage: PercentageDTOHelper.format(tax.iva_percentage, locale), iva_amount: MoneyDTOHelper.format(tax.iva_amount, locale, moneyOptions), rec_code: tax.rec_code ?? "", + rec_name: tax.rec_name ?? "", rec_percentage: PercentageDTOHelper.format(tax.rec_percentage, locale), rec_amount: MoneyDTOHelper.format(tax.rec_amount, locale, moneyOptions), retention_code: tax.retention_code ?? "", + retention_name: tax.retention_name ?? "", retention_percentage: PercentageDTOHelper.format(tax.retention_percentage, locale), retention_amount: MoneyDTOHelper.format(tax.retention_amount, locale, moneyOptions), diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-tax-snapshot.interface.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-tax-snapshot.interface.ts index cd7c5082..6c232e48 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-tax-snapshot.interface.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-tax-snapshot.interface.ts @@ -2,14 +2,17 @@ export interface IIssuedInvoiceReportTaxSnapshot { taxable_amount: string; iva_code: string; + iva_name: string; iva_percentage: string; iva_amount: string; rec_code: string; + rec_name: string; rec_percentage: string; rec_amount: string; retention_code: string; + retention_name: string; retention_percentage: string; retention_amount: string; diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-finder.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-finder.di.ts index e673d320..36ecdbe3 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-finder.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-finder.di.ts @@ -1,6 +1,9 @@ import type { IProformaRepository } from "../repositories"; -import { type IProformaFinder, ProformaFinder } from "../services"; +import { type IProformaFinder, type ProformaTaxResolver, ProformaFinder } from "../services"; -export function buildProformaFinder(repository: IProformaRepository): IProformaFinder { - return new ProformaFinder(repository); +export function buildProformaFinder(params: { + repository: IProformaRepository; + taxResolver: ProformaTaxResolver; +}): IProformaFinder { + return new ProformaFinder(params); } diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-status-changer.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-status-changer.di.ts index c580029e..4fe31dfd 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-status-changer.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-status-changer.di.ts @@ -1,10 +1,15 @@ import type { IAudit } from "@repo/rdx-audit"; import type { IProformaRepository } from "../repositories"; -import { type IProformaStatusChanger, ProformaStatusChanger } from "../services"; +import { + type IProformaStatusChanger, + type ProformaTaxAvailabilityChecker, + ProformaStatusChanger, +} from "../services"; export const buildProformaStatusChanger = (params: { repository: IProformaRepository; + taxAvailabilityChecker: ProformaTaxAvailabilityChecker; audit: IAudit; }): IProformaStatusChanger => { return new ProformaStatusChanger(params); 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 e35e82ad..4602a996 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 @@ -161,6 +161,7 @@ export function buildIssueProformaUseCase(deps: { issuedInvoiceServices: IIssuedInvoicePublicServices; }; finder: IProformaFinder; + readinessChecker: IProformaIssuanceReadinessChecker; issuer: IProformaIssuer; issueReadModelAssembler: IProformaIssueReadModelAssembler; issueResponseSnapshotBuilder: IIssueProformaResponseSnapshotBuilder; @@ -170,6 +171,7 @@ export function buildIssueProformaUseCase(deps: { }) { const { finder, + readinessChecker, issuer, issueReadModelAssembler, issueResponseSnapshotBuilder, @@ -182,6 +184,7 @@ export function buildIssueProformaUseCase(deps: { return new IssueProformaUseCase({ issuedInvoiceServices, finder, + readinessChecker, issuer, issueReadModelAssembler, issueResponseSnapshotBuilder, diff --git a/modules/customer-invoices/src/api/application/proformas/errors/index.ts b/modules/customer-invoices/src/api/application/proformas/errors/index.ts index 315fe5a9..d276bdea 100644 --- a/modules/customer-invoices/src/api/application/proformas/errors/index.ts +++ b/modules/customer-invoices/src/api/application/proformas/errors/index.ts @@ -1 +1,3 @@ export * from "./company-report-profile-not-found.error"; +export * from "./proforma-taxes-unavailable.error"; +export * from "./proforma-not-ready-for-issuance.error"; diff --git a/modules/customer-invoices/src/api/application/proformas/errors/proforma-not-ready-for-issuance.error.ts b/modules/customer-invoices/src/api/application/proformas/errors/proforma-not-ready-for-issuance.error.ts new file mode 100644 index 00000000..751d9402 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/errors/proforma-not-ready-for-issuance.error.ts @@ -0,0 +1,14 @@ +import type { ProformaIssuanceReadinessModel } from "../models"; + +export class ProformaNotReadyForIssuanceError extends Error { + public readonly code = "PROFORMA_NOT_READY_FOR_ISSUANCE"; + + public constructor(public readonly blockers: ProformaIssuanceReadinessModel["blockers"]) { + super("Proforma is not ready for issuance."); + this.name = "ProformaNotReadyForIssuanceError"; + } +} + +export const isProformaNotReadyForIssuanceError = ( + error: unknown +): error is ProformaNotReadyForIssuanceError => error instanceof ProformaNotReadyForIssuanceError; diff --git a/modules/customer-invoices/src/api/application/proformas/errors/proforma-taxes-unavailable.error.ts b/modules/customer-invoices/src/api/application/proformas/errors/proforma-taxes-unavailable.error.ts new file mode 100644 index 00000000..265afe80 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/errors/proforma-taxes-unavailable.error.ts @@ -0,0 +1,14 @@ +import type { ProformaTaxAvailabilityEntry } from "../services/proforma-tax-availability-checker"; + +export class ProformaTaxesUnavailableError extends Error { + public readonly code = "PROFORMA_TAXES_UNAVAILABLE"; + + public constructor(public readonly entries: ProformaTaxAvailabilityEntry[]) { + super("Proforma status cannot be changed while applied taxes are unavailable."); + this.name = "ProformaTaxesUnavailableError"; + } +} + +export const isProformaTaxesUnavailableError = ( + error: unknown +): error is ProformaTaxesUnavailableError => error instanceof ProformaTaxesUnavailableError; diff --git a/modules/customer-invoices/src/api/application/proformas/models/proforma-issuance-readiness.model.ts b/modules/customer-invoices/src/api/application/proformas/models/proforma-issuance-readiness.model.ts index a6aa2b48..f984a6ac 100644 --- a/modules/customer-invoices/src/api/application/proformas/models/proforma-issuance-readiness.model.ts +++ b/modules/customer-invoices/src/api/application/proformas/models/proforma-issuance-readiness.model.ts @@ -4,5 +4,9 @@ export interface ProformaIssuanceReadinessModel { canIssue: boolean; blockers: Array<{ code: GetProformaIssuanceReadinessResponseDTO["blockers"][number]["code"]; + itemId?: string; + taxKind?: "iva" | "rec" | "retention"; + taxCode?: string; + reason?: "inactive" | "out_of_validity" | "missing"; }>; } diff --git a/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-resolver.test.ts b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-resolver.test.ts new file mode 100644 index 00000000..b61372f7 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-resolver.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { ITaxDefinitionPublicFinder, TaxDefinitionPublicModel } from "@erp/catalogs/api"; +import { Tax, TaxPercentage } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Collection, Maybe, Result } from "@repo/rdx-utils"; + +import type { TaxDefinitionToTaxMapper } from "../../../common/mappers"; + +import { ProformaTaxResolver } from "./proforma-tax-resolver"; + +const required = (result: { data: T }): T => result.data; + +const createTax = (params: { + code: string; + name: string; + value: number; + group: "iva" | "surcharge" | "retention"; + calculationBehavior: "additive" | "subtractive"; +}) => + required( + Tax.create({ + code: params.code, + name: params.name, + rate: required(TaxPercentage.create({ value: params.value })), + group: params.group, + calculationBehavior: params.calculationBehavior, + }) + ); + +const buildResolver = (liveTaxes: Tax[]) => { + const finder = { + findByCodes: async () => + Result.ok( + new Collection(liveTaxes.map((tax) => ({ code: tax.code } as TaxDefinitionPublicModel))) + ), + } as unknown as ITaxDefinitionPublicFinder; + const taxMapper = { + toTax: (definition: TaxDefinitionPublicModel) => + Result.ok(liveTaxes.find((tax) => tax.code === definition.code)!), + } as unknown as TaxDefinitionToTaxMapper; + + return new ProformaTaxResolver({ + taxDefinitionFinder: finder, + taxRegimeFinder: {} as never, + taxMapper, + }); +}; + +test("ProformaTaxResolver sustituye definiciones vivas y conserva las missing", async () => { + const persistedIva = createTax({ + code: "iva_21", + name: "IVA anterior", + value: 2100, + group: "iva", + calculationBehavior: "additive", + }); + const persistedRec = createTax({ + code: "rec_5_2", + name: "REC persistido", + value: 520, + group: "surcharge", + calculationBehavior: "additive", + }); + const persistedRetention = createTax({ + code: "retention_15", + name: "Retención anterior", + value: 1500, + group: "retention", + calculationBehavior: "subtractive", + }); + const liveIva = createTax({ + code: "iva_21", + name: "IVA actualizado", + value: 2200, + group: "iva", + calculationBehavior: "additive", + }); + const liveRetention = createTax({ + code: "retention_15", + name: "Retención actualizada", + value: 700, + group: "retention", + calculationBehavior: "subtractive", + }); + const resolver = buildResolver([liveIva, liveRetention]); + + const result = await resolver.resolveExistingItemTaxes({ + companyId: required(UniqueID.create("01900000-0000-7000-8000-000000000501")), + itemsTaxes: new Collection([ + { + iva: Maybe.some(persistedIva), + rec: Maybe.some(persistedRec), + retention: Maybe.some(persistedRetention), + }, + ]), + }); + + assert.equal(result.isSuccess, true); + const taxes = result.data.getAll()[0]; + assert.equal(taxes.iva.unwrap().equalsTo(liveIva), true); + assert.equal(taxes.rec.unwrap().equalsTo(persistedRec), true); + assert.equal(taxes.retention.unwrap().equalsTo(liveRetention), true); + assert.equal(taxes.iva.unwrap().value, 2200); + assert.notEqual(taxes.iva.unwrap().value, persistedIva.value); +}); diff --git a/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-resolver.ts b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-resolver.ts index 0d0fa49f..558e2c20 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-resolver.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-resolver.ts @@ -31,6 +31,12 @@ export interface FindActiveProformaTaxDefinitionByCodeParams { transaction?: unknown; } +export interface ResolveExistingProformaItemTaxesParams { + companyId: UniqueID; + itemsTaxes: Collection; + transaction?: unknown; +} + /** * Resuelve datos fiscales externos al agregado de proforma usando servicios públicos de `catalogs`. * @@ -136,6 +142,54 @@ export class ProformaTaxResolver { }); } + public async resolveExistingItemTaxes( + params: ResolveExistingProformaItemTaxesParams + ): Promise, Error>> { + const codes = new Collection( + Array.from( + new Set( + params.itemsTaxes + .getAll() + .flatMap((taxes) => [taxes.iva, taxes.rec, taxes.retention]) + .flatMap((tax) => (tax.isSome() ? [tax.unwrap().code] : [])) + ) + ) + ); + + if (codes.size() === 0) { + return Result.ok(params.itemsTaxes); + } + + const definitions = await this.deps.taxDefinitionFinder.findByCodes({ + companyId: params.companyId, + codes, + transaction: params.transaction, + }); + + if (definitions.isFailure) { + return Result.fail(definitions.error); + } + + const liveTaxesByCode = new Map(); + for (const definition of definitions.data.getAll()) { + const taxResult = this.deps.taxMapper.toTax(definition); + if (taxResult.isFailure) { + return Result.fail(taxResult.error); + } + liveTaxesByCode.set(taxResult.data.code, taxResult.data); + } + + return Result.ok( + new Collection( + params.itemsTaxes.getAll().map((taxes) => ({ + iva: this.replaceWithLiveTax(taxes.iva, liveTaxesByCode), + rec: this.replaceWithLiveTax(taxes.rec, liveTaxesByCode), + retention: this.replaceWithLiveTax(taxes.retention, liveTaxesByCode), + })) + ) + ); + } + private collectCodes(taxCodes: ProformaItemTaxCodesInput): Collection { const codes = [taxCodes.ivaCode, taxCodes.recCode, taxCodes.retentionCode] .filter((code) => code.isSome()) @@ -167,4 +221,13 @@ export class ProformaTaxResolver { return Result.ok(Maybe.some(tax.data)); } + + private replaceWithLiveTax(tax: Maybe, liveTaxesByCode: Map): Maybe { + if (tax.isNone()) { + return tax; + } + + const liveTax = liveTaxesByCode.get(tax.unwrap().code); + return liveTax ? Maybe.some(liveTax) : tax; + } } diff --git a/modules/customer-invoices/src/api/application/proformas/services/index.ts b/modules/customer-invoices/src/api/application/proformas/services/index.ts index 82800bb0..2ee74bdf 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/index.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/index.ts @@ -15,4 +15,5 @@ export * from "./proforma-public-services.interface"; export * from "./proforma-series-validator.interface"; export * from "./proforma-status-changer"; export * from "./proforma-target-invoice-series-validator.interface"; +export * from "./proforma-tax-availability-checker"; export * from "./proforma-updater"; diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-finder.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-finder.ts index 4e20be57..ac33416e 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-finder.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-finder.ts @@ -6,6 +6,7 @@ import type { Proforma } from "../../../domain"; import type { ProformaStatusCounts, ProformaSummary } from "../models"; import type { IProformaRepository } from "../repositories"; import type { ProformaListScope } from "../use-cases"; +import type { ProformaTaxResolver } from "./catalog-resolver"; export interface IProformaFinder { findProformaById( @@ -42,14 +43,20 @@ export interface IProformaFinder { } export class ProformaFinder implements IProformaFinder { - constructor(private readonly repository: IProformaRepository) {} + constructor( + private readonly deps: { + repository: IProformaRepository; + taxResolver: ProformaTaxResolver; + } + ) {} async findProformaById( companyId: UniqueID, proformaId: UniqueID, transaction?: unknown ): Promise> { - return this.repository.getByIdInCompany(companyId, proformaId, transaction); + const result = await this.deps.repository.getByIdInCompany(companyId, proformaId, transaction); + return this.resolveLiveTaxes(result, transaction); } async findProformaByIdIncludingArchived( @@ -57,7 +64,12 @@ export class ProformaFinder implements IProformaFinder { proformaId: UniqueID, transaction?: unknown ): Promise> { - return this.repository.getByIdIncludingArchivedInCompany(companyId, proformaId, transaction); + const result = await this.deps.repository.getByIdIncludingArchivedInCompany( + companyId, + proformaId, + transaction + ); + return this.resolveLiveTaxes(result, transaction); } async proformaExists( @@ -65,7 +77,7 @@ export class ProformaFinder implements IProformaFinder { proformaId: UniqueID, transaction?: unknown ): Promise> { - return this.repository.existsByIdInCompany(companyId, proformaId, transaction); + return this.deps.repository.existsByIdInCompany(companyId, proformaId, transaction); } async findProformasByCriteria( @@ -74,7 +86,7 @@ export class ProformaFinder implements IProformaFinder { criteria: Criteria, transaction?: unknown ): Promise, Error>> { - return this.repository.findByCriteriaInCompany(companyId, scope, criteria, transaction); + return this.deps.repository.findByCriteriaInCompany(companyId, scope, criteria, transaction); } async countProformasByStatus( @@ -83,6 +95,28 @@ export class ProformaFinder implements IProformaFinder { criteria: Criteria, transaction?: unknown ): Promise> { - return this.repository.countByStatusInCompany(companyId, scope, criteria, transaction); + return this.deps.repository.countByStatusInCompany(companyId, scope, criteria, transaction); + } + + private async resolveLiveTaxes( + result: Result, + transaction?: unknown + ): Promise> { + if (result.isFailure) { + return Result.fail(result.error); + } + + const proforma = result.data; + const taxesResult = await this.deps.taxResolver.resolveExistingItemTaxes({ + companyId: proforma.companyId, + itemsTaxes: new Collection(proforma.items.getAll().map((item) => item.taxes.getProps())), + transaction, + }); + + if (taxesResult.isFailure) { + return Result.fail(taxesResult.error); + } + + return proforma.withResolvedItemTaxes(taxesResult.data); } } diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-issuance-readiness-checker.test.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-issuance-readiness-checker.test.ts index f0d71c6a..6ccabecd 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-issuance-readiness-checker.test.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-issuance-readiness-checker.test.ts @@ -3,7 +3,7 @@ import test from "node:test"; import { DiscountPercentage } from "@erp/core/api"; import { CurrencyCode, LanguageCode, UniqueID, UtcDate } from "@repo/rdx-ddd"; -import { Maybe, Result } from "@repo/rdx-utils"; +import { Collection, Maybe, Result } from "@repo/rdx-utils"; import { InvoiceNumber, @@ -89,6 +89,7 @@ const buildApprovedProforma = (params?: { test("check devuelve canIssue true cuando la proforma y el payment method son válidos", async () => { const checker = new ProformaIssuanceReadinessChecker({ + taxAvailabilityChecker: { checkProformaTaxes: async () => Result.ok(new Collection([])) } as never, paymentMethodFinder: { findByIdInCompany: async () => Result.ok( @@ -117,6 +118,7 @@ test("check devuelve canIssue true cuando la proforma y el payment method son v test("check acumula blockers internos sin abortar en el primero", async () => { const checker = new ProformaIssuanceReadinessChecker({ + taxAvailabilityChecker: { checkProformaTaxes: async () => Result.ok(new Collection([])) } as never, paymentMethodFinder: { findByIdInCompany: async () => Result.ok(Maybe.none()), } as never, @@ -150,6 +152,7 @@ test("check acumula blockers internos sin abortar en el primero", async () => { test("check informa payment_method_unavailable cuando el finder no resuelve la referencia company-scoped", async () => { const checker = new ProformaIssuanceReadinessChecker({ + taxAvailabilityChecker: { checkProformaTaxes: async () => Result.ok(new Collection([])) } as never, paymentMethodFinder: { findByIdInCompany: async () => Result.ok(Maybe.none()), } as never, @@ -170,6 +173,7 @@ test("check informa payment_method_unavailable cuando el finder no resuelve la r test("check informa payment_method_inactive cuando la referencia resuelta está inactiva", async () => { const checker = new ProformaIssuanceReadinessChecker({ + taxAvailabilityChecker: { checkProformaTaxes: async () => Result.ok(new Collection([])) } as never, paymentMethodFinder: { findByIdInCompany: async () => Result.ok( diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-issuance-readiness-checker.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-issuance-readiness-checker.ts index f8ab718e..3d58a5d7 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-issuance-readiness-checker.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-issuance-readiness-checker.ts @@ -4,6 +4,7 @@ import { Result } from "@repo/rdx-utils"; import type { Proforma } from "../../../domain"; import type { ProformaIssuanceReadinessModel } from "../models"; +import type { ProformaTaxAvailabilityChecker } from "./proforma-tax-availability-checker"; export interface IProformaIssuanceReadinessChecker { check(params: { @@ -17,6 +18,7 @@ export class ProformaIssuanceReadinessChecker implements IProformaIssuanceReadin public constructor( private readonly deps: { paymentMethodFinder: IPaymentMethodPublicFinder; + taxAvailabilityChecker: ProformaTaxAvailabilityChecker; } ) {} @@ -55,6 +57,23 @@ export class ProformaIssuanceReadinessChecker implements IProformaIssuanceReadin } } + const taxesResult = await this.deps.taxAvailabilityChecker.checkProformaTaxes(params); + if (taxesResult.isFailure) { + return Result.fail(taxesResult.error); + } + + for (const entry of taxesResult.data.getAll()) { + if (entry.availability !== "available") { + blockers.push({ + code: "tax_unavailable", + itemId: entry.itemId.toPrimitive(), + taxKind: entry.taxKind, + taxCode: entry.taxCode, + reason: entry.availability, + }); + } + } + return Result.ok({ canIssue: blockers.length === 0, blockers, diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-status-changer.test.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-status-changer.test.ts index c7552391..cefb02c6 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-status-changer.test.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-status-changer.test.ts @@ -7,10 +7,11 @@ import type { RecordAuditInput, RecordAuditMutationShortcutInput, } from "@repo/rdx-audit"; -import { Result } from "@repo/rdx-utils"; +import { Collection, Result } from "@repo/rdx-utils"; import { InvoiceStatus } from "../../../domain"; import { createProformaTestFixture } from "../../../domain/proformas/testing/proforma-test-fixture"; +import { ProformaTaxesUnavailableError } from "../errors"; import { ProformaStatusChanger } from "./proforma-status-changer"; @@ -45,6 +46,9 @@ test("cambia draft a sent y registra proforma.status_changed", async () => { return Result.ok(); }, } as never, + taxAvailabilityChecker: { + checkProformaTaxes: async () => Result.ok(new Collection([])), + } as never, audit, }); @@ -82,6 +86,9 @@ test("no persiste ni audita draft a draft", async () => { return Result.ok(); }, } as never, + taxAvailabilityChecker: { + checkProformaTaxes: async () => Result.ok(new Collection([])), + } as never, audit, }); @@ -110,6 +117,9 @@ test("rechaza una transición manual inválida sin persistir ni auditar", async return Result.ok(); }, } as never, + taxAvailabilityChecker: { + checkProformaTaxes: async () => Result.ok(new Collection([])), + } as never, audit, }); @@ -123,3 +133,50 @@ test("rechaza una transición manual inválida sin persistir ni auditar", async assert.equal(updateCalls, 0); assert.equal(audit.recordMutationCalls.length, 0); }); + +test("rechaza impuestos no disponibles sin persistir ni auditar", async () => { + const proforma = createProformaTestFixture(); + const audit = new FakeAudit(); + let updateCalls = 0; + const changer = new ProformaStatusChanger({ + repository: { + getByIdInCompany: async () => Result.ok(proforma), + update: async () => { + updateCalls += 1; + return Result.ok(); + }, + } as never, + taxAvailabilityChecker: { + checkProformaTaxes: async () => + Result.ok( + new Collection([ + { + itemId: proforma.id, + taxKind: "iva" as const, + taxCode: "iva_21", + availability: "inactive" as const, + }, + { + itemId: proforma.id, + taxKind: "retention" as const, + taxCode: "retention_15", + availability: "missing" as const, + }, + ]) + ), + } as never, + audit, + }); + + const result = await changer.changeStatus({ + companyId: proforma.companyId, + id: proforma.id, + newStatus: "sent", + }); + + assert.equal(result.isFailure, true); + assert.equal(result.error instanceof ProformaTaxesUnavailableError, true); + assert.equal((result.error as ProformaTaxesUnavailableError).entries.length, 2); + assert.equal(updateCalls, 0); + assert.equal(audit.recordMutationCalls.length, 0); +}); diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-status-changer.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-status-changer.ts index 8f624d72..a5115e78 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-status-changer.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-status-changer.ts @@ -8,7 +8,9 @@ import { toProformaAuditState, toProformaAuditTarget, } from "../audit/proforma-audit"; +import { ProformaTaxesUnavailableError } from "../errors"; import type { IProformaRepository } from "../repositories"; +import type { ProformaTaxAvailabilityChecker } from "./proforma-tax-availability-checker"; export interface IProformaStatusChanger { changeStatus(params: { @@ -23,6 +25,7 @@ export class ProformaStatusChanger implements IProformaStatusChanger { public constructor( private readonly deps: { repository: IProformaRepository; + taxAvailabilityChecker: ProformaTaxAvailabilityChecker; audit: IAudit; } ) {} @@ -50,8 +53,34 @@ export class ProformaStatusChanger implements IProformaStatusChanger { } const proforma = proformaResult.data; - const before = toProformaAuditState(proforma); + const transitionResult = proforma.ensureCanChangeStatus(statusResult.data); + if (transitionResult.isFailure) { + return Result.fail(transitionResult.error); + } + + if (!transitionResult.data) { + return Result.ok(proforma); + } + + const availabilityResult = await this.deps.taxAvailabilityChecker.checkProformaTaxes({ + companyId: params.companyId, + proforma, + transaction: params.transaction, + }); + + if (availabilityResult.isFailure) { + return Result.fail(availabilityResult.error); + } + + const unavailableTaxes = availabilityResult.data + .getAll() + .filter((entry) => entry.availability !== "available"); + if (unavailableTaxes.length > 0) { + return Result.fail(new ProformaTaxesUnavailableError(unavailableTaxes)); + } + + const before = toProformaAuditState(proforma); const changeResult = proforma.changeStatus(statusResult.data); if (changeResult.isFailure) { diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-tax-availability-checker.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-tax-availability-checker.ts new file mode 100644 index 00000000..6953e50b --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-tax-availability-checker.ts @@ -0,0 +1,99 @@ +import type { ITaxDefinitionPublicFinder, TaxDefinitionPublicModel } from "@erp/catalogs/api"; +import type { Tax } from "@erp/core/api"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { Collection, Result } from "@repo/rdx-utils"; + +import type { Proforma } from "../../../domain"; + +export type ProformaTaxKind = "iva" | "rec" | "retention"; +export type ProformaTaxAvailability = "available" | "inactive" | "out_of_validity" | "missing"; + +export interface ProformaTaxAvailabilityEntry { + itemId: UniqueID; + taxKind: ProformaTaxKind; + taxCode: string; + availability: ProformaTaxAvailability; +} + +export class ProformaTaxAvailabilityChecker { + public constructor( + private readonly taxDefinitionFinder: ITaxDefinitionPublicFinder + ) {} + + public async checkProformaTaxes(params: { + companyId: UniqueID; + proforma: Proforma; + transaction?: unknown; + }): Promise, Error>> { + const references: Array<{ + itemId: UniqueID; + taxKind: ProformaTaxKind; + tax: Tax; + }> = []; + for (const item of params.proforma.items.getAll()) { + for (const [taxKind, tax] of [ + ["iva", item.taxes.iva], + ["rec", item.taxes.rec], + ["retention", item.taxes.retention], + ] as const) { + if (tax.isSome()) { + references.push({ itemId: item.id, taxKind, tax: tax.unwrap() }); + } + } + } + const codes = new Collection(Array.from(new Set(references.map((reference) => reference.tax.code)))); + + if (codes.size() === 0) { + return Result.ok(new Collection([])); + } + + const definitions = await this.taxDefinitionFinder.findByCodes({ + companyId: params.companyId, + codes, + transaction: params.transaction, + }); + if (definitions.isFailure) { + return Result.fail(definitions.error); + } + + const definitionsByCode = new Map( + definitions.data.getAll().map((definition) => [definition.code, definition]) + ); + const date = params.proforma.proformaDate.toPrimitive(); + + return Result.ok( + new Collection( + references.map((reference) => ({ + itemId: reference.itemId, + taxKind: reference.taxKind, + taxCode: reference.tax.code, + availability: this.classify(definitionsByCode.get(reference.tax.code), date), + })) + ) + ); + } + + private classify( + definition: TaxDefinitionPublicModel | undefined, + date: string + ): ProformaTaxAvailability { + if (!definition) { + return "missing"; + } + + if (!definition.isActive) { + return "inactive"; + } + + const validFrom = definition.validFrom.map((value) => value.toPrimitive()); + const validTo = definition.validTo.map((value) => value.toPrimitive()); + if ( + (validFrom.isSome() && validFrom.unwrap() > date) || + (validTo.isSome() && validTo.unwrap() < date) + ) { + return "out_of_validity"; + } + + return "available"; + } +} diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-items-full-snapshot-builder.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-items-full-snapshot-builder.ts index 0b4af5db..e9442325 100644 --- a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-items-full-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-items-full-snapshot-builder.ts @@ -53,6 +53,7 @@ export class ProformaItemsFullSnapshotBuilder implements IProformaItemsFullSnaps : ItemAmount.zero(currencyCode).toObjectString(), iva_code: maybeToNullable(proformaItem.ivaCode(), (value) => value.toString()), + iva_name: maybeToNullable(proformaItem.taxes.iva, (tax) => tax.name), iva_percentage: maybeToNullable(proformaItem.ivaPercentage(), (value) => value.toObjectString() ), @@ -61,6 +62,7 @@ export class ProformaItemsFullSnapshotBuilder implements IProformaItemsFullSnaps : ItemAmount.zero(currencyCode).toObjectString(), rec_code: maybeToNullable(proformaItem.recCode(), (value) => value.toString()), + rec_name: maybeToNullable(proformaItem.taxes.rec, (tax) => tax.name), rec_percentage: maybeToNullable(proformaItem.recPercentage(), (value) => value.toObjectString() ), @@ -69,6 +71,7 @@ export class ProformaItemsFullSnapshotBuilder implements IProformaItemsFullSnaps : ItemAmount.zero(currencyCode).toObjectString(), retention_code: maybeToNullable(proformaItem.retentionCode(), (value) => value.toString()), + retention_name: maybeToNullable(proformaItem.taxes.retention, (tax) => tax.name), retention_percentage: maybeToNullable(proformaItem.retentionPercentage(), (value) => value.toObjectString() ), diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-taxes-full-snapshot-builder.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-taxes-full-snapshot-builder.ts index 5d882fa9..44310ca4 100644 --- a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-taxes-full-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-taxes-full-snapshot-builder.ts @@ -14,14 +14,17 @@ export class ProformaTaxesFullSnapshotBuilder implements IProformaTaxesFullSnaps taxable_amount: proformaTax.taxableAmount.toObjectString(), iva_code: maybeToNullable(proformaTax.ivaCode, (value) => value.toString()), + iva_name: maybeToNullable(proformaTax.ivaName, (value) => value.toString()), iva_percentage: maybeToNullable(proformaTax.ivaPercentage, (value) => value.toObjectString()), iva_amount: proformaTax.ivaAmount.toObjectString(), rec_code: maybeToNullable(proformaTax.recCode, (value) => value.toString()), + rec_name: maybeToNullable(proformaTax.recName, (value) => value.toString()), rec_percentage: maybeToNullable(proformaTax.recPercentage, (value) => value.toObjectString()), rec_amount: proformaTax.recAmount.toObjectString(), retention_code: maybeToNullable(proformaTax.retentionCode, (value) => value.toString()), + retention_name: maybeToNullable(proformaTax.retentionName, (value) => value.toString()), retention_percentage: maybeToNullable(proformaTax.retentionPercentage, (value) => value.toObjectString() ), diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/issuance-readiness/proforma-issuance-readiness.snapshot-builder.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/issuance-readiness/proforma-issuance-readiness.snapshot-builder.ts index 0c01752b..66d275ae 100644 --- a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/issuance-readiness/proforma-issuance-readiness.snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/issuance-readiness/proforma-issuance-readiness.snapshot-builder.ts @@ -16,6 +16,10 @@ export class ProformaIssuanceReadinessSnapshotBuilder can_issue: source.canIssue, blockers: source.blockers.map((blocker) => ({ code: blocker.code, + ...(blocker.itemId ? { item_id: blocker.itemId } : {}), + ...(blocker.taxKind ? { tax_kind: blocker.taxKind } : {}), + ...(blocker.taxCode ? { tax_code: blocker.taxCode } : {}), + ...(blocker.reason ? { reason: blocker.reason } : {}), })), }; } diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.test.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.test.ts index 68fae39c..b9397721 100644 --- a/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.test.ts +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.test.ts @@ -228,6 +228,7 @@ test("issue rechaza un payment term cross-tenant y no crea factura parcial", asy return Result.ok({ id: UniqueID.generateNewID() } as never); }, } as never, + readinessChecker: { check: async () => Result.ok({ canIssue: true, blockers: [] }) } as never, finder: { findProformaById: async () => Result.ok({ @@ -286,6 +287,7 @@ test("issue falla antes de persistir si la proforma no tiene operation date", as return Result.ok({ id: UniqueID.generateNewID() } as never); }, } as never, + readinessChecker: { check: async () => Result.ok({ canIssue: true, blockers: [] }) } as never, finder: { findProformaById: async () => Result.ok(proforma), } as never, @@ -353,6 +355,7 @@ test("issue crea VerifactuRecord pending cuando la empresa usa VeriFactu", async getIssuedInvoiceById: async (id) => Result.ok(createReloadedIssuedInvoice(id, proforma.companyId)), } as never, + readinessChecker: { check: async () => Result.ok({ canIssue: true, blockers: [] }) } as never, finder: { findProformaById: async () => Result.ok(proforma), } as never, @@ -421,6 +424,7 @@ test("issue no crea VerifactuRecord cuando la empresa no usa VeriFactu", async ( getIssuedInvoiceById: async (id) => Result.ok(createReloadedIssuedInvoice(id, proforma.companyId)), } as never, + readinessChecker: { check: async () => Result.ok({ canIssue: true, blockers: [] }) } as never, finder: { findProformaById: async () => Result.ok(proforma), } as never, @@ -480,6 +484,7 @@ test("issue propaga el fallo de persistencia VeriFactu y no marca la proforma co issuedInvoiceServices: { createIssuedInvoice: async () => Result.fail(new Error("verifactu insert failed")), } as never, + readinessChecker: { check: async () => Result.ok({ canIssue: true, blockers: [] }) } as never, finder: { findProformaById: async () => Result.ok(proforma), } as never, @@ -553,6 +558,7 @@ test("issue registra issued_invoice.created con el estado final de la factura", return Result.ok(reloadedInvoice); }, }, + readinessChecker: { check: async () => Result.ok({ canIssue: true, blockers: [] }) } as never, finder: { findProformaById: async () => Result.ok(proforma) }, issuer: { issueProforma: (): Result => 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 a6321cf1..9fec19b5 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 @@ -7,9 +7,11 @@ import type { IIssuedInvoicePublicServices } from "../../issued-invoices"; import { toIssuedInvoiceAuditState } from "../../issued-invoices/audit/issued-invoice-audit"; import { VerifactuRecord, VerifactuRecordEstado } from "../../../domain"; import type { IssueProformaCommand } from "../commands"; +import { ProformaNotReadyForIssuanceError } from "../errors"; import type { IProformaRepository } from "../repositories"; import type { IProformaFinder, + IProformaIssuanceReadinessChecker, IProformaIssueReadModelAssembler, IProformaIssuer, } from "../services"; @@ -30,6 +32,7 @@ export class IssueProformaUseCase { issuedInvoiceServices: IIssuedInvoicePublicServices; audit: IAudit; finder: IProformaFinder; + readinessChecker: IProformaIssuanceReadinessChecker; issuer: IProformaIssuer; issueReadModelAssembler: IProformaIssueReadModelAssembler; issueResponseSnapshotBuilder: IIssueProformaResponseSnapshotBuilder; @@ -70,6 +73,18 @@ export class IssueProformaUseCase { const proforma = proformaResult.data; + const readinessResult = await this.deps.readinessChecker.check({ + companyId, + proforma, + transaction, + }); + if (readinessResult.isFailure) { + return Result.fail(readinessResult.error); + } + if (!readinessResult.data.canIssue) { + return Result.fail(new ProformaNotReadyForIssuanceError(readinessResult.data.blockers)); + } + // 2. Generamos la factura definitiva const issuedInvoiceId = UniqueID.generateNewID(); diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/report-proforma-pdf.use-case.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/report-proforma-pdf.use-case.ts index e9d642c5..5421a9d1 100644 --- a/modules/customer-invoices/src/api/application/proformas/use-cases/report-proforma-pdf.use-case.ts +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/report-proforma-pdf.use-case.ts @@ -91,7 +91,7 @@ export class ReportProformaPdfUseCase { }); const documentResult = await this.deps.documentService.generate(reportSnapshot, { - companySlug: reportSnapshot.company.slug, + companySlug: "", //reportSnapshot.company.slug, }); if (documentResult.isFailure) { diff --git a/modules/customer-invoices/src/api/domain/issued-invoices/entities/issued-invoice-items/issued-invoice-item.entity.ts b/modules/customer-invoices/src/api/domain/issued-invoices/entities/issued-invoice-items/issued-invoice-item.entity.ts index 69d509b9..76d78702 100644 --- a/modules/customer-invoices/src/api/domain/issued-invoices/entities/issued-invoice-items/issued-invoice-item.entity.ts +++ b/modules/customer-invoices/src/api/domain/issued-invoices/entities/issued-invoice-items/issued-invoice-item.entity.ts @@ -33,14 +33,17 @@ export interface IIssuedInvoiceItemCreateProps { taxableAmount: ItemAmount; ivaCode: Maybe; + ivaName: Maybe; ivaPercentage: Maybe; ivaAmount: ItemAmount; recCode: Maybe; + recName: Maybe; recPercentage: Maybe; recAmount: ItemAmount; retentionCode: Maybe; + retentionName: Maybe; retentionPercentage: Maybe; retentionAmount: ItemAmount; @@ -72,14 +75,17 @@ export interface IIssuedInvoiceItem { taxableAmount: ItemAmount; ivaCode: Maybe; + ivaName: Maybe; ivaPercentage: Maybe; ivaAmount: ItemAmount; recCode: Maybe; + recName: Maybe; recPercentage: Maybe; recAmount: ItemAmount; retentionCode: Maybe; + retentionName: Maybe; retentionPercentage: Maybe; retentionAmount: ItemAmount; @@ -100,6 +106,9 @@ export class IssuedInvoiceItem props: IIssuedInvoiceItemCreateProps, id?: UniqueID ): Result { + const taxNamesResult = validateTaxNames(props); + if (taxNamesResult.isFailure) return Result.fail(taxNamesResult.error); + const item = new IssuedInvoiceItem(props, id); // Reglas de negocio / validaciones @@ -169,6 +178,9 @@ export class IssuedInvoiceItem public get ivaCode(): Maybe { return this.props.ivaCode; } + public get ivaName(): Maybe { + return this.props.ivaName; + } public get ivaPercentage(): Maybe { return this.props.ivaPercentage; } @@ -179,6 +191,9 @@ export class IssuedInvoiceItem public get recCode(): Maybe { return this.props.recCode; } + public get recName(): Maybe { + return this.props.recName; + } public get recPercentage(): Maybe { return this.props.recPercentage; } @@ -189,6 +204,9 @@ export class IssuedInvoiceItem public get retentionCode(): Maybe { return this.props.retentionCode; } + public get retentionName(): Maybe { + return this.props.retentionName; + } public get retentionPercentage(): Maybe { return this.props.retentionPercentage; } @@ -216,3 +234,16 @@ export class IssuedInvoiceItem return this.props.quantity.isSome() || this.props.unitAmount.isSome(); } } + +function validateTaxNames(props: IIssuedInvoiceItemCreateProps): Result { + for (const [kind, code, name] of [ + ["IVA", props.ivaCode, props.ivaName], + ["REC", props.recCode, props.recName], + ["retention", props.retentionCode, props.retentionName], + ] as const) { + if (code.isSome() !== name.isSome()) { + return Result.fail(new Error(`Issued invoice item ${kind} code and name must be present together`)); + } + } + return Result.ok(undefined); +} diff --git a/modules/customer-invoices/src/api/domain/issued-invoices/entities/issued-invoice-taxes/issued-invoice-tax.entity.ts b/modules/customer-invoices/src/api/domain/issued-invoices/entities/issued-invoice-taxes/issued-invoice-tax.entity.ts index ca1952bd..825027d9 100644 --- a/modules/customer-invoices/src/api/domain/issued-invoices/entities/issued-invoice-taxes/issued-invoice-tax.entity.ts +++ b/modules/customer-invoices/src/api/domain/issued-invoices/entities/issued-invoice-taxes/issued-invoice-tax.entity.ts @@ -8,14 +8,17 @@ export type IssuedInvoiceTaxProps = { taxableAmount: InvoiceAmount; ivaCode: string; + ivaName: string; ivaPercentage: Percentage; ivaAmount: InvoiceAmount; recCode: Maybe; + recName: Maybe; recPercentage: Maybe; recAmount: InvoiceAmount; retentionCode: Maybe; + retentionName: Maybe; retentionPercentage: Maybe; retentionAmount: InvoiceAmount; @@ -27,6 +30,13 @@ export class IssuedInvoiceTax extends DomainEntity { props: IssuedInvoiceTaxProps, id?: UniqueID ): Result { + if (props.ivaName.length === 0) return Result.fail(new Error("Issued invoice tax IVA name is required")); + if (props.recCode.isSome() !== props.recName.isSome()) { + return Result.fail(new Error("Issued invoice tax REC code and name must be present together")); + } + if (props.retentionCode.isSome() !== props.retentionName.isSome()) { + return Result.fail(new Error("Issued invoice tax retention code and name must be present together")); + } return Result.ok(new IssuedInvoiceTax(props, id)); } @@ -37,6 +47,9 @@ export class IssuedInvoiceTax extends DomainEntity { public get ivaCode(): string { return this.props.ivaCode; } + public get ivaName(): string { + return this.props.ivaName; + } public get ivaPercentage(): TaxPercentage { return this.props.ivaPercentage; } @@ -47,6 +60,9 @@ export class IssuedInvoiceTax extends DomainEntity { public get recCode(): Maybe { return this.props.recCode; } + public get recName(): Maybe { + return this.props.recName; + } public get recPercentage(): Maybe { return this.props.recPercentage; } @@ -57,6 +73,9 @@ export class IssuedInvoiceTax extends DomainEntity { public get retentionCode(): Maybe { return this.props.retentionCode; } + public get retentionName(): Maybe { + return this.props.retentionName; + } public get retentionPercentage(): Maybe { return this.props.retentionPercentage; } diff --git a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts index 1176552c..6d67311f 100644 --- a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts +++ b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts @@ -573,6 +573,21 @@ export class Proforma extends AggregateRoot implements IP * crear una factura emitida y debe ejecutarse mediante el caso de uso de emisión. */ public changeStatus(nextStatus: InvoiceStatus): Result { + const validationResult = this.ensureCanChangeStatus(nextStatus); + if (validationResult.isFailure) { + return Result.fail(validationResult.error); + } + + if (!validationResult.data) { + return Result.ok(false); + } + + this.props.status = nextStatus; + + return Result.ok(true); + } + + public ensureCanChangeStatus(nextStatus: InvoiceStatus): Result { const currentStatus = this.status; if (currentStatus.toPrimitive() === nextStatus.toPrimitive()) { @@ -589,8 +604,6 @@ export class Proforma extends AggregateRoot implements IP ); } - this.props.status = nextStatus; - return Result.ok(true); } @@ -822,6 +835,34 @@ export class Proforma extends AggregateRoot implements IP return this.items.taxes(this.calculationContext()); } + public withResolvedItemTaxes( + itemsTaxes: Collection + ): Result { + const currentItems = this._items.getAll(); + const resolvedTaxes = itemsTaxes.getAll(); + + if (currentItems.length !== resolvedTaxes.length) { + return Result.fail(new Error("Resolved item taxes count does not match proforma items")); + } + + const items: ProformaItem[] = []; + for (const [index, item] of currentItems.entries()) { + const taxesResult = ProformaItemTaxes.create(resolvedTaxes[index]); + if (taxesResult.isFailure) { + return Result.fail(taxesResult.error); + } + + items.push( + ProformaItem.rehydrate( + { ...item.getProps(), taxes: taxesResult.data }, + item.id + ) + ); + } + + return Result.ok(Proforma.rehydrate(this.props, ProformaItems.create({ items }), this.id)); + } + public addItem(props: IProformaItemCreateProps): Result { const taxesResult = ProformaItemTaxes.create(props.taxes); if (taxesResult.isFailure) return Result.fail(taxesResult.error); diff --git a/modules/customer-invoices/src/api/domain/proformas/services/proforma-compute-tax-groups.ts b/modules/customer-invoices/src/api/domain/proformas/services/proforma-compute-tax-groups.ts index 5e9f383b..70cb083a 100644 --- a/modules/customer-invoices/src/api/domain/proformas/services/proforma-compute-tax-groups.ts +++ b/modules/customer-invoices/src/api/domain/proformas/services/proforma-compute-tax-groups.ts @@ -10,14 +10,17 @@ type TaxGroupState = { taxableAmount: ItemAmount; ivaCode: Maybe; + ivaName: Maybe; ivaPercentage: Maybe; ivaAmount: ItemAmount; recCode: Maybe; + recName: Maybe; recPercentage: Maybe; recAmount: ItemAmount; retentionCode: Maybe; + retentionName: Maybe; retentionPercentage: Maybe; retentionAmount: ItemAmount; @@ -51,14 +54,17 @@ export function proformaComputeTaxGroups( taxableAmount: ItemAmount.zero(currency.code), ivaCode: iva.isSome() ? Maybe.some(iva.unwrap().code) : Maybe.none(), + ivaName: iva.isSome() ? Maybe.some(iva.unwrap().name) : Maybe.none(), ivaPercentage: iva.isSome() ? Maybe.some(iva.unwrap().percentage) : Maybe.none(), ivaAmount: ItemAmount.zero(currency.code), recCode: rec.isSome() ? Maybe.some(rec.unwrap().code) : Maybe.none(), + recName: rec.isSome() ? Maybe.some(rec.unwrap().name) : Maybe.none(), recPercentage: rec.isSome() ? Maybe.some(rec.unwrap().percentage) : Maybe.none(), recAmount: ItemAmount.zero(currency.code), retentionCode: retention.isSome() ? Maybe.some(retention.unwrap().code) : Maybe.none(), + retentionName: retention.isSome() ? Maybe.some(retention.unwrap().name) : Maybe.none(), retentionPercentage: retention.isSome() ? Maybe.some(retention.unwrap().percentage) : Maybe.none(), @@ -70,6 +76,13 @@ export function proformaComputeTaxGroups( const g = map.get(key)!; + ensureTaxNameMatches(g.ivaCode, g.ivaName, iva, "IVA"); + ensureTaxNameMatches(g.recCode, g.recName, rec, "REC"); + ensureTaxNameMatches(g.retentionCode, g.retentionName, retention, "retention"); + ensureTaxPercentageMatches(g.ivaCode, g.ivaPercentage, iva, "IVA"); + ensureTaxPercentageMatches(g.recCode, g.recPercentage, rec, "REC"); + ensureTaxPercentageMatches(g.retentionCode, g.retentionPercentage, retention, "retention"); + const itemTotals = item.totals(context); g.taxableAmount = g.taxableAmount.add(itemTotals.taxableAmount); @@ -81,6 +94,35 @@ export function proformaComputeTaxGroups( return map; } +function ensureTaxNameMatches( + code: Maybe, + name: Maybe, + tax: Maybe, + kind: string +): void { + if (code.isNone() || tax.isNone()) return; + if (code.unwrap() !== tax.unwrap().code || name.isNone() || name.unwrap() !== tax.unwrap().name) { + throw new Error(`Inconsistent ${kind} tax metadata in grouped proforma taxes`); + } +} + +function ensureTaxPercentageMatches( + code: Maybe, + percentage: Maybe, + tax: Maybe, + kind: string +): void { + if (code.isNone() || tax.isNone()) return; + if ( + code.unwrap() !== tax.unwrap().code || + percentage.isNone() || + percentage.unwrap().value !== tax.unwrap().percentage.value || + percentage.unwrap().scale !== tax.unwrap().percentage.scale + ) { + throw new Error(`Inconsistent ${kind} tax percentage in grouped proforma taxes`); + } +} + function buildTaxGroupKey(iva: Maybe, rec: Maybe, retention: Maybe): string { const ivaPart = iva.isSome() ? `${iva.unwrap().code}` : "NULL"; const recPart = rec.isSome() ? `${rec.unwrap().code}` : "NULL"; diff --git a/modules/customer-invoices/src/api/domain/proformas/services/proforma-taxes-calculator.test.ts b/modules/customer-invoices/src/api/domain/proformas/services/proforma-taxes-calculator.test.ts new file mode 100644 index 00000000..f383c39f --- /dev/null +++ b/modules/customer-invoices/src/api/domain/proformas/services/proforma-taxes-calculator.test.ts @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Tax, TaxPercentage } from "@erp/core/api"; +import { Maybe } from "@repo/rdx-utils"; + +import { ItemAmount, ItemQuantity } from "../../common"; +import type { IProformaItemCreateProps } from "../entities"; +import { createProformaTestFixture } from "../testing/proforma-test-fixture"; + +const required = (result: { data: T }): T => result.data; + +const createTax = (params: { + code: string; + name: string; + value: number; + group: "iva" | "surcharge" | "retention"; + calculationBehavior: "additive" | "subtractive"; +}) => + required( + Tax.create({ + code: params.code, + name: params.name, + rate: required(TaxPercentage.create({ value: params.value })), + group: params.group, + calculationBehavior: params.calculationBehavior, + }) + ); + +const createItem = (taxes: IProformaItemCreateProps["taxes"]): IProformaItemCreateProps => ({ + description: Maybe.none(), + quantity: Maybe.some(required(ItemQuantity.create({ value: 1 }))), + unitAmount: Maybe.some(required(ItemAmount.create({ value: 10000, currency_code: "EUR" }))), + itemDiscountPercentage: Maybe.none(), + taxes, +}); + +test("Proforma.taxes conserva los nombres de IVA, REC y retención de las líneas", () => { + const iva = createTax({ + code: "iva_21", + name: "IVA general real", + value: 2100, + group: "iva", + calculationBehavior: "additive", + }); + const rec = createTax({ + code: "rec_5_2", + name: "Recargo de equivalencia 5,2 %", + value: 520, + group: "surcharge", + calculationBehavior: "additive", + }); + const retention = createTax({ + code: "retention_15", + name: "Retención IRPF 15 %", + value: 1500, + group: "retention", + calculationBehavior: "subtractive", + }); + const proforma = createProformaTestFixture({ + items: [ + createItem({ + iva: Maybe.some(iva), + rec: Maybe.some(rec), + retention: Maybe.some(retention), + }), + ], + }); + + const [taxes] = proforma.taxes().getAll(); + + assert.equal(taxes.ivaCode.unwrap(), iva.code); + assert.equal(taxes.ivaName.unwrap(), iva.name); + assert.equal(taxes.recCode.unwrap(), rec.code); + assert.equal(taxes.recName.unwrap(), rec.name); + assert.equal(taxes.retentionCode.unwrap(), retention.code); + assert.equal(taxes.retentionName.unwrap(), retention.name); +}); + +test("Proforma.taxes mantiene code y name ausentes juntos para REC y retención", () => { + const iva = createTax({ + code: "iva_21", + name: "IVA general real", + value: 2100, + group: "iva", + calculationBehavior: "additive", + }); + const proforma = createProformaTestFixture({ + items: [createItem({ iva: Maybe.some(iva), rec: Maybe.none(), retention: Maybe.none() })], + }); + + const [taxes] = proforma.taxes().getAll(); + + assert.equal(taxes.recCode.isNone(), true); + assert.equal(taxes.recName.isNone(), true); + assert.equal(taxes.retentionCode.isNone(), true); + assert.equal(taxes.retentionName.isNone(), true); +}); + +test("Proforma.taxes rechaza el mismo código agrupado con nombres fiscales distintos", () => { + const firstIva = createTax({ + code: "iva_21", + name: "IVA general histórico", + value: 2100, + group: "iva", + calculationBehavior: "additive", + }); + const secondIva = createTax({ + code: "iva_21", + name: "IVA general actualizado", + value: 2100, + group: "iva", + calculationBehavior: "additive", + }); + const proforma = createProformaTestFixture({ + items: [ + createItem({ iva: Maybe.some(firstIva), rec: Maybe.none(), retention: Maybe.none() }), + createItem({ iva: Maybe.some(secondIva), rec: Maybe.none(), retention: Maybe.none() }), + ], + }); + + assert.throws(() => proforma.taxes(), /Inconsistent IVA tax metadata/); +}); + +test("Proforma.taxes rechaza el mismo código agrupado con porcentajes distintos", () => { + const iva21 = createTax({ + code: "iva", + name: "IVA", + value: 2100, + group: "iva", + calculationBehavior: "additive", + }); + const iva10 = createTax({ + code: "iva", + name: "IVA", + value: 1000, + group: "iva", + calculationBehavior: "additive", + }); + const proforma = createProformaTestFixture({ + items: [ + createItem({ iva: Maybe.some(iva21), rec: Maybe.none(), retention: Maybe.none() }), + createItem({ iva: Maybe.some(iva10), rec: Maybe.none(), retention: Maybe.none() }), + ], + }); + + assert.throws(() => proforma.taxes(), /Inconsistent IVA tax percentage/); +}); diff --git a/modules/customer-invoices/src/api/domain/proformas/services/proforma-taxes-calculator.ts b/modules/customer-invoices/src/api/domain/proformas/services/proforma-taxes-calculator.ts index b557aa3f..38a17932 100644 --- a/modules/customer-invoices/src/api/domain/proformas/services/proforma-taxes-calculator.ts +++ b/modules/customer-invoices/src/api/domain/proformas/services/proforma-taxes-calculator.ts @@ -12,14 +12,17 @@ export interface IProformaTaxTotals { taxableAmount: InvoiceAmount; ivaCode: Maybe; + ivaName: Maybe; ivaPercentage: Maybe; ivaAmount: InvoiceAmount; recCode: Maybe; + recName: Maybe; recPercentage: Maybe; recAmount: InvoiceAmount; retentionCode: Maybe; + retentionName: Maybe; retentionPercentage: Maybe; retentionAmount: InvoiceAmount; @@ -47,14 +50,17 @@ export class ProformaTaxesCalculator { taxableAmount, ivaCode: g.ivaCode, + ivaName: g.ivaName, ivaPercentage: g.ivaPercentage, ivaAmount, recCode: g.recCode, + recName: g.recName, recPercentage: g.recPercentage, recAmount, retentionCode: g.retentionCode, + retentionName: g.retentionName, retentionPercentage: g.retentionPercentage, retentionAmount, diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-issued-invoice-item.model.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-issued-invoice-item.model.ts index 1a8050de..f94ec878 100644 --- a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-issued-invoice-item.model.ts +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-issued-invoice-item.model.ts @@ -62,6 +62,7 @@ export class IssuedInvoiceItemModel extends Model< // IVA percentage declare iva_code: CreationOptional; + declare iva_name: CreationOptional; declare iva_percentage_value: CreationOptional; declare iva_percentage_scale: number; @@ -72,6 +73,7 @@ export class IssuedInvoiceItemModel extends Model< // Recargo de equivalencia percentage declare rec_code: CreationOptional; + declare rec_name: CreationOptional; declare rec_percentage_value: CreationOptional; declare rec_percentage_scale: number; @@ -82,6 +84,7 @@ export class IssuedInvoiceItemModel extends Model< // Retention percentage declare retention_code: CreationOptional; + declare retention_name: CreationOptional; declare retention_percentage_value: CreationOptional; declare retention_percentage_scale: number; @@ -264,6 +267,11 @@ export default (database: Sequelize) => { allowNull: true, defaultValue: null, }, + iva_name: { + type: DataTypes.STRING(100), + allowNull: true, + defaultValue: null, + }, iva_percentage_value: { type: DataTypes.SMALLINT, allowNull: true, @@ -292,6 +300,11 @@ export default (database: Sequelize) => { allowNull: true, defaultValue: null, }, + rec_name: { + type: DataTypes.STRING(100), + allowNull: true, + defaultValue: null, + }, rec_percentage_value: { type: DataTypes.SMALLINT, @@ -321,6 +334,11 @@ export default (database: Sequelize) => { allowNull: true, defaultValue: null, }, + retention_name: { + type: DataTypes.STRING(100), + allowNull: true, + defaultValue: null, + }, retention_percentage_value: { type: DataTypes.SMALLINT, diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-issued-invoice-tax.model.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-issued-invoice-tax.model.ts index 77c93a9e..625c2f33 100644 --- a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-issued-invoice-tax.model.ts +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-issued-invoice-tax.model.ts @@ -24,16 +24,19 @@ export class IssuedInvoiceTaxModel extends Model< declare taxable_amount_value: number; declare taxable_amount_scale: number; declare iva_code: CreationOptional; + declare iva_name: CreationOptional; declare iva_percentage_value: CreationOptional; declare iva_percentage_scale: number; declare iva_amount_value: CreationOptional; declare iva_amount_scale: number; declare rec_code: CreationOptional; + declare rec_name: CreationOptional; declare rec_percentage_value: CreationOptional; declare rec_percentage_scale: number; declare rec_amount_value: CreationOptional; declare rec_amount_scale: number; declare retention_code: CreationOptional; + declare retention_name: CreationOptional; declare retention_percentage_value: CreationOptional; declare retention_percentage_scale: number; declare retention_amount_value: CreationOptional; @@ -98,6 +101,11 @@ export default (database: Sequelize) => { allowNull: true, defaultValue: null, }, + iva_name: { + type: DataTypes.STRING(100), + allowNull: true, + defaultValue: null, + }, iva_percentage_value: { type: DataTypes.SMALLINT, allowNull: true, @@ -123,6 +131,11 @@ export default (database: Sequelize) => { allowNull: true, defaultValue: null, }, + rec_name: { + type: DataTypes.STRING(100), + allowNull: true, + defaultValue: null, + }, rec_percentage_value: { type: DataTypes.SMALLINT, allowNull: true, @@ -148,6 +161,11 @@ export default (database: Sequelize) => { allowNull: true, defaultValue: null, }, + retention_name: { + type: DataTypes.STRING(100), + allowNull: true, + defaultValue: null, + }, retention_percentage_value: { type: DataTypes.SMALLINT, allowNull: true, @@ -191,11 +209,6 @@ export default (database: Sequelize) => { name: "issued_invoice_id_idx", fields: ["issued_invoice_id"], }, - { - name: "issued_invoice_iva_code_unique", - fields: ["issued_invoice_id", "iva_code"], - unique: true, - }, ], whereMergeStrategy: "and", defaultScope: {}, diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-proforma-item.model.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-proforma-item.model.ts index 6456882c..976860d0 100644 --- a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-proforma-item.model.ts +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-proforma-item.model.ts @@ -42,16 +42,25 @@ export class ProformaItemModel extends Model< declare taxable_amount_value: number; declare taxable_amount_scale: number; declare iva_code: CreationOptional; + declare iva_name: CreationOptional; + declare iva_group: CreationOptional; + declare iva_calculation_behavior: CreationOptional; declare iva_percentage_value: CreationOptional; declare iva_percentage_scale: number; declare iva_amount_value: number; declare iva_amount_scale: number; declare rec_code: CreationOptional; + declare rec_name: CreationOptional; + declare rec_group: CreationOptional; + declare rec_calculation_behavior: CreationOptional; declare rec_percentage_value: CreationOptional; declare rec_percentage_scale: number; declare rec_amount_value: number; declare rec_amount_scale: number; declare retention_code: CreationOptional; + declare retention_name: CreationOptional; + declare retention_group: CreationOptional; + declare retention_calculation_behavior: CreationOptional; declare retention_percentage_value: CreationOptional; declare retention_percentage_scale: number; declare retention_amount_value: number; @@ -207,6 +216,9 @@ export default (database: Sequelize) => { allowNull: true, defaultValue: null, }, + iva_name: { type: DataTypes.STRING(100), allowNull: true, defaultValue: null }, + iva_group: { type: DataTypes.STRING(40), allowNull: true, defaultValue: null }, + iva_calculation_behavior: { type: DataTypes.STRING(40), allowNull: true, defaultValue: null }, iva_percentage_value: { type: DataTypes.SMALLINT, allowNull: true, @@ -232,6 +244,9 @@ export default (database: Sequelize) => { allowNull: true, defaultValue: null, }, + rec_name: { type: DataTypes.STRING(100), allowNull: true, defaultValue: null }, + rec_group: { type: DataTypes.STRING(40), allowNull: true, defaultValue: null }, + rec_calculation_behavior: { type: DataTypes.STRING(40), allowNull: true, defaultValue: null }, rec_percentage_value: { type: DataTypes.SMALLINT, allowNull: true, @@ -257,6 +272,9 @@ export default (database: Sequelize) => { allowNull: true, defaultValue: null, }, + retention_name: { type: DataTypes.STRING(100), allowNull: true, defaultValue: null }, + retention_group: { type: DataTypes.STRING(40), allowNull: true, defaultValue: null }, + retention_calculation_behavior: { type: DataTypes.STRING(40), allowNull: true, defaultValue: null }, retention_percentage_value: { type: DataTypes.SMALLINT, allowNull: true, diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-proforma-tax.model.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-proforma-tax.model.ts index 05befc5d..2bec2ea6 100644 --- a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-proforma-tax.model.ts +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-proforma-tax.model.ts @@ -191,11 +191,6 @@ export default (database: Sequelize) => { name: "proforma_id_idx", fields: ["proforma_id"], }, - { - name: "proforma_iva_code_unique", - fields: ["proforma_id", "iva_code"], - unique: true, - }, ], whereMergeStrategy: "and", defaultScope: {}, diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-item-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-item-domain.mapper.ts index b49561aa..369062c5 100644 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-item-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-item-domain.mapper.ts @@ -132,6 +132,8 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap errors ); const ivaCode = maybeFromNullableOrEmptyString(raw.iva_code); + const ivaName = maybeFromNullableOrEmptyString(raw.iva_name); + ensureCodeAndName(ivaCode, ivaName, `items[${index}].iva`, errors); const ivaPercentage = extractOrPushError( maybeFromNullableResult(raw.iva_percentage_value, (value) => TaxPercentage.create({ value })), `items[${index}].iva_percentage_value`, @@ -146,6 +148,8 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap errors ); const recCode = maybeFromNullableOrEmptyString(raw.rec_code); + const recName = maybeFromNullableOrEmptyString(raw.rec_name); + ensureCodeAndName(recCode, recName, `items[${index}].rec`, errors); const recPercentage = extractOrPushError( maybeFromNullableResult(raw.rec_percentage_value, (value) => TaxPercentage.create({ value })), `items[${index}].rec_percentage_value`, @@ -160,6 +164,8 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap errors ); const retentionCode = maybeFromNullableOrEmptyString(raw.retention_code); + const retentionName = maybeFromNullableOrEmptyString(raw.retention_name); + ensureCodeAndName(retentionCode, retentionName, `items[${index}].retention`, errors); const retentionPercentage = extractOrPushError( maybeFromNullableResult(raw.retention_percentage_value, (value) => TaxPercentage.create({ value }) @@ -207,12 +213,15 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap totalDiscountAmount, taxableAmount, ivaCode, + ivaName, ivaPercentage, ivaAmount, recCode, + recName, recPercentage, recAmount, retentionCode, + retentionName, retentionPercentage, retentionAmount, taxesAmount, @@ -250,12 +259,15 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap totalDiscountAmount: attributes.totalDiscountAmount!, taxableAmount: attributes.taxableAmount!, ivaCode: attributes.ivaCode!, + ivaName: attributes.ivaName!, ivaPercentage: attributes.ivaPercentage!, ivaAmount: attributes.ivaAmount!, recCode: attributes.recCode!, + recName: attributes.recName!, recPercentage: attributes.recPercentage!, recAmount: attributes.recAmount!, retentionCode: attributes.retentionCode!, + retentionName: attributes.retentionName!, retentionPercentage: attributes.retentionPercentage!, retentionAmount: attributes.retentionAmount!, taxesAmount: attributes.taxesAmount!, @@ -324,6 +336,7 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap // IVA iva_code: maybeToNullableString(source.ivaCode), + iva_name: maybeToNullableString(source.ivaName), iva_percentage_value: maybeToNullable(source.ivaPercentage, (v) => v.toPrimitive().value), iva_percentage_scale: @@ -334,6 +347,7 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap // REC rec_code: maybeToNullableString(source.recCode), + rec_name: maybeToNullableString(source.recName), rec_percentage_value: maybeToNullable(source.recPercentage, (v) => v.toPrimitive().value), rec_percentage_scale: @@ -344,6 +358,7 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap // RET retention_code: maybeToNullableString(source.retentionCode), + retention_name: maybeToNullableString(source.retentionName), retention_percentage_value: maybeToNullable( source.retentionPercentage, @@ -365,3 +380,14 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap } satisfies IssuedInvoiceItemCreationAttributes); } } + +function ensureCodeAndName( + code: ReturnType, + name: ReturnType, + path: string, + errors: ValidationErrorDetail[] +): void { + if (code.isSome() !== name.isSome()) { + errors.push({ path, message: "tax code and name must be present together" }); + } +} diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-taxes-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-taxes-domain.mapper.ts index 951243c3..d4443d89 100644 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-taxes-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-taxes-domain.mapper.ts @@ -59,6 +59,10 @@ export class SequelizeIssuedInvoiceV2TaxesDomainMapper extends SequelizeDomainMa errors ); const ivaCode = raw.iva_code; + const ivaName = raw.iva_name; + if (typeof ivaCode !== "string" || ivaCode.length === 0 || typeof ivaName !== "string" || ivaName.length === 0) { + errors.push({ path: `taxes[${index}].iva`, message: "tax code and name must be present together" }); + } const ivaPercentage = extractOrPushError( TaxPercentage.create({ value: Number(raw.iva_percentage_value) }), `taxes[${index}].iva_percentage_value`, @@ -70,6 +74,8 @@ export class SequelizeIssuedInvoiceV2TaxesDomainMapper extends SequelizeDomainMa errors ); const recCode = maybeFromNullableOrEmptyString(raw.rec_code); + const recName = maybeFromNullableOrEmptyString(raw.rec_name); + ensureCodeAndName(recCode, recName, `taxes[${index}].rec`, errors); const recPercentage = extractOrPushError( maybeFromNullableResult(raw.rec_percentage_value, (value) => TaxPercentage.create({ value })), `taxes[${index}].rec_percentage_value`, @@ -81,6 +87,8 @@ export class SequelizeIssuedInvoiceV2TaxesDomainMapper extends SequelizeDomainMa errors ); const retentionCode = maybeFromNullableOrEmptyString(raw.retention_code); + const retentionName = maybeFromNullableOrEmptyString(raw.retention_name); + ensureCodeAndName(retentionCode, retentionName, `taxes[${index}].retention`, errors); const retentionPercentage = extractOrPushError( maybeFromNullableResult(raw.retention_percentage_value, (value) => TaxPercentage.create({ value })), `taxes[${index}].retention_percentage_value`, @@ -109,12 +117,15 @@ export class SequelizeIssuedInvoiceV2TaxesDomainMapper extends SequelizeDomainMa return IssuedInvoiceTax.create({ taxableAmount: taxableAmount!, ivaCode: ivaCode!, + ivaName: ivaName!, ivaPercentage: ivaPercentage!, ivaAmount: ivaAmount!, recCode, + recName, recPercentage: recPercentage!, recAmount: recAmount!, retentionCode, + retentionName, retentionPercentage: retentionPercentage!, retentionAmount: retentionAmount!, taxesAmount: taxesAmount!, @@ -137,11 +148,13 @@ export class SequelizeIssuedInvoiceV2TaxesDomainMapper extends SequelizeDomainMa taxable_amount_value: source.taxableAmount.value, taxable_amount_scale: source.taxableAmount.scale, iva_code: source.ivaCode, + iva_name: source.ivaName, iva_percentage_value: source.ivaPercentage.value, iva_percentage_scale: source.ivaPercentage.scale, iva_amount_value: source.ivaAmount.value, iva_amount_scale: source.ivaAmount.scale, rec_code: maybeToNullableString(source.recCode), + rec_name: maybeToNullableString(source.recName), rec_percentage_value: maybeToNullable(source.recPercentage, (v) => v.toPrimitive().value), rec_percentage_scale: maybeToNullable(source.recPercentage, (v) => v.toPrimitive().scale) ?? @@ -149,6 +162,7 @@ export class SequelizeIssuedInvoiceV2TaxesDomainMapper extends SequelizeDomainMa rec_amount_value: source.recAmount.toPrimitive().value, rec_amount_scale: source.recAmount.toPrimitive().scale ?? ItemAmount.DEFAULT_SCALE, retention_code: maybeToNullableString(source.retentionCode), + retention_name: maybeToNullableString(source.retentionName), retention_percentage_value: maybeToNullable( source.retentionPercentage, (v) => v.toPrimitive().value @@ -167,3 +181,14 @@ export class SequelizeIssuedInvoiceV2TaxesDomainMapper extends SequelizeDomainMa } } } + +function ensureCodeAndName( + code: ReturnType, + name: ReturnType, + path: string, + errors: ValidationErrorDetail[] +): void { + if (code.isSome() !== name.isSome()) { + errors.push({ path, message: "tax code and name must be present together" }); + } +} diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts index 6257e525..98e3c140 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts @@ -67,7 +67,6 @@ export function buildProformaPublicServices( documentSeriesServices, }); const { repository, numberGenerator: numberService } = persistence; - const finder = buildProformaFinder(repository); const catalogResolvers = buildProformaCatalogResolvers({ taxDefinitionFinder: catalogs.taxDefinition.finder, @@ -75,6 +74,7 @@ export function buildProformaPublicServices( paymentMethodFinder: catalogs.paymentMethod.finder, paymentTermFinder: catalogs.paymentTerm.finder, }); + const finder = buildProformaFinder({ repository, taxResolver: catalogResolvers.taxResolver }); const readModelAssemblers = buildProformaReadModelAssemblers({ paymentMethodFinder: catalogs.paymentMethod.finder, 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 14e0b4de..06910cfc 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 @@ -16,6 +16,7 @@ import { type ListProformasUseCase, type PreviewProformaReportUseCase, ProformaIssuanceReadinessChecker, + ProformaTaxAvailabilityChecker, ProformaTaxConfigResolver, RecurringProformaActivationReadinessChecker, RecurringProformaCommands, @@ -156,7 +157,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter taxRegimeFinder: catalogs.taxRegime.finder, }); - const finder = buildProformaFinder(repository); + const finder = buildProformaFinder({ repository, taxResolver: catalogResolvers.taxResolver }); const proformaSeriesValidator = new DocumentSeriesProformaSeriesValidator(documentSeriesServices); const targetInvoiceSeriesValidator = new DocumentSeriesProformaTargetInvoiceSeriesValidator( documentSeriesServices @@ -282,6 +283,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter const statusChanger = buildProformaStatusChanger({ repository, + taxAvailabilityChecker: new ProformaTaxAvailabilityChecker(catalogs.taxDefinition.finder), audit: params.audit, }); @@ -300,6 +302,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter }); const issuanceReadinessChecker = new ProformaIssuanceReadinessChecker({ paymentMethodFinder: catalogs.paymentMethod.finder, + taxAvailabilityChecker: new ProformaTaxAvailabilityChecker(catalogs.taxDefinition.finder), }); const companyReportProfileFinder = new CompanyReportProfileFinder({ @@ -332,6 +335,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter listProformas: () => buildListProformasUseCase({ finder, + readinessChecker: issuanceReadinessChecker, summarySnapshotBuilder: snapshotBuilders.summary, transactionManager, }), @@ -430,6 +434,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter buildIssueProformaUseCase({ publicServices, finder, + readinessChecker: issuanceReadinessChecker, issuer, issueReadModelAssembler, issueResponseSnapshotBuilder: snapshotBuilders.issue, diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/documents/services/proforma-pdf-document-metadata-factory.ts b/modules/customer-invoices/src/api/infrastructure/proformas/documents/services/proforma-pdf-document-metadata-factory.ts index faa35e0f..b1675785 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/documents/services/proforma-pdf-document-metadata-factory.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/documents/services/proforma-pdf-document-metadata-factory.ts @@ -5,20 +5,23 @@ import type { ProformaReportSnapshot } from "../../../../application"; export class ProformaPdfDocumentMetadataFactory implements IDocumentMetadataFactory { - build(snapshot: ProformaReportSnapshot): IDocumentMetadata { + build(snapshot: ProformaReportSnapshot, context: Record): IDocumentMetadata { return { documentType: "proforma", documentId: snapshot.id, - companyId: snapshot.company_id, - companySlug: snapshot.company.slug, + companyId: context.company_id, + companySlug: context.company.slug, format: "PDF", - languageCode: snapshot.localization.languageCode, + languageCode: context.localization.languageCode, filename: `proforma-${snapshot.proforma_reference}.pdf`, storageKey: this.buildStorageKey(snapshot), }; } - private buildStorageKey(snapshot: ProformaReportSnapshot): string | undefined { + private buildStorageKey( + snapshot: ProformaReportSnapshot, + context: Record + ): string | undefined { if (snapshot.status === "draft") { return undefined; } @@ -32,12 +35,12 @@ export class ProformaPdfDocumentMetadataFactory snapshot.id, snapshot.status, "pdf", - snapshot.localization.languageCode, - snapshot.localization.currencyCode, - snapshot.company.slug, + context.localization.languageCode, + context.localization.currencyCode, + context.company.slug, ]; - const companyVersion = snapshot.company.version.getOrUndefined(); + const companyVersion = context.company.version.getOrUndefined(); if (companyVersion) { keyParts.push(companyVersion); diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas-api-error-mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas-api-error-mapper.ts index 2ca24147..2ec335fd 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas-api-error-mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas-api-error-mapper.ts @@ -14,8 +14,12 @@ import { InvoiceSeriesNotFoundError, InvoiceSeriesTransactionRequiredError, IssuedInvoiceNumberAlreadyExistsError, + ProformaTaxesUnavailableError, + ProformaNotReadyForIssuanceError, isCompanyReportProfileNotFoundError, isIssuedInvoiceNumberAlreadyExistsError, + isProformaTaxesUnavailableError, + isProformaNotReadyForIssuanceError, } from "../../../application"; import { type CustomerInvoiceIdAlreadyExistsError, @@ -78,6 +82,22 @@ const proformaTransitionRule: ErrorToApiRule = { ), }; +const proformaTaxesUnavailableRule: ErrorToApiRule = { + priority: 120, + matches: (e) => isProformaTaxesUnavailableError(e), + build: (e) => + new ConflictApiError( + (e as ProformaTaxesUnavailableError).message || + "Proforma status cannot be changed while applied taxes are unavailable." + ), +}; + +const proformaNotReadyForIssuanceRule: ErrorToApiRule = { + priority: 120, + matches: (e) => isProformaNotReadyForIssuanceError(e), + build: (e) => new ConflictApiError((e as ProformaNotReadyForIssuanceError).message), +}; + const proformaConversionRule: ErrorToApiRule = { priority: 120, matches: (e) => isProformaCannotBeConvertedToInvoiceError(e), @@ -227,4 +247,6 @@ export const proformasApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default() .register(proformaCannotBeUnarchivedRule) .register(proformaCannotBeArchivedRule) .register(proformaCannotBeDeletedRule) + .register(proformaTaxesUnavailableRule) + .register(proformaNotReadyForIssuanceRule) .register(proformaTransitionRule); diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-item-domain.mapper.test.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-item-domain.mapper.test.ts new file mode 100644 index 00000000..e57e2f8f --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-item-domain.mapper.test.ts @@ -0,0 +1,207 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Tax, TaxPercentage } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe } from "@repo/rdx-utils"; + +import { type Proforma, ProformaItem } from "../../../../../../domain"; +import type { ProformaItemModel } from "../../../../../common"; + +import { SequelizeProformaV2ItemDomainMapper } from "./sequelize-proforma-v2-item-domain.mapper"; + +const required = (result: { data: T }): T => result.data; + +const parent = { + id: required(UniqueID.create("01900000-0000-7000-8000-000000000401")), + currencyCode: { code: "EUR" }, + languageCode: { code: "es" }, + globalDiscountPercentage: { toPrimitive: () => ({ value: 0, scale: 2 }) }, +} as unknown as Proforma; + +const createTax = (params: { + code: string; + name: string; + value: number; + group: "iva" | "surcharge" | "retention"; + calculationBehavior: "additive" | "subtractive"; +}) => + required( + Tax.create({ + code: params.code, + name: params.name, + rate: required(TaxPercentage.create({ value: params.value })), + group: params.group, + calculationBehavior: params.calculationBehavior, + }) + ); + +const roundTrip = (taxes: Parameters[0]["taxes"]) => { + const source = required( + ProformaItem.create({ + description: Maybe.none(), + quantity: Maybe.none(), + unitAmount: Maybe.none(), + itemDiscountPercentage: Maybe.none(), + taxes, + }) + ); + const mapper = new SequelizeProformaV2ItemDomainMapper(); + const persistence = required( + mapper.mapToPersistence(source, { index: 0, parent, errors: [] }) + ); + const rehydrated = required( + mapper.mapToDomain(persistence as unknown as ProformaItemModel, { + index: 0, + parent: { currencyCode: parent.currencyCode }, + errors: [], + }) + ); + + return { persistence, rehydrated }; +}; + +test("SequelizeProformaV2ItemDomainMapper conserva IVA completo sin fabricar name", () => { + const iva = createTax({ + code: "iva_21", + name: "IVA general real", + value: 2100, + group: "iva", + calculationBehavior: "additive", + }); + const { persistence, rehydrated } = roundTrip({ + iva: Maybe.some(iva), + rec: Maybe.none(), + retention: Maybe.none(), + }); + + assert.equal(persistence.iva_code, "iva_21"); + assert.equal(persistence.iva_name, "IVA general real"); + assert.equal(persistence.iva_group, "iva"); + assert.equal(persistence.iva_calculation_behavior, "additive"); + assert.equal(rehydrated.taxes.iva.unwrap().name, "IVA general real"); + assert.notEqual( + rehydrated.taxes.iva.unwrap().name, + rehydrated.taxes.iva.unwrap().code + ); +}); + +test("SequelizeProformaV2ItemDomainMapper conserva REC completo", () => { + const rec = createTax({ + code: "rec_5_2", + name: "Recargo de equivalencia 5,2 %", + value: 520, + group: "surcharge", + calculationBehavior: "additive", + }); + const { persistence, rehydrated } = roundTrip({ + iva: Maybe.none(), + rec: Maybe.some(rec), + retention: Maybe.none(), + }); + + assert.equal(persistence.rec_name, rec.name); + assert.equal(persistence.rec_group, rec.group); + assert.equal(persistence.rec_calculation_behavior, rec.calculationBehavior); + assert.equal(rehydrated.taxes.rec.unwrap().equalsTo(rec), true); +}); + +test("SequelizeProformaV2ItemDomainMapper conserva retención completa", () => { + const retention = createTax({ + code: "retention_15", + name: "Retención IRPF 15 %", + value: 1500, + group: "retention", + calculationBehavior: "subtractive", + }); + const { persistence, rehydrated } = roundTrip({ + iva: Maybe.none(), + rec: Maybe.none(), + retention: Maybe.some(retention), + }); + + assert.equal(persistence.retention_name, retention.name); + assert.equal(persistence.retention_group, retention.group); + assert.equal(persistence.retention_calculation_behavior, retention.calculationBehavior); + assert.equal(rehydrated.taxes.retention.unwrap().equalsTo(retention), true); +}); + +test("SequelizeProformaV2ItemDomainMapper conserva los tres impuestos y las ausencias", () => { + const iva = createTax({ + code: "iva_21", + name: "IVA general", + value: 2100, + group: "iva", + calculationBehavior: "additive", + }); + const rec = createTax({ + code: "rec_5_2", + name: "Recargo de equivalencia 5,2 %", + value: 520, + group: "surcharge", + calculationBehavior: "additive", + }); + const retention = createTax({ + code: "retention_15", + name: "Retención IRPF 15 %", + value: 1500, + group: "retention", + calculationBehavior: "subtractive", + }); + const { rehydrated } = roundTrip({ + iva: Maybe.some(iva), + rec: Maybe.some(rec), + retention: Maybe.some(retention), + }); + + assert.equal(rehydrated.taxes.iva.unwrap().equalsTo(iva), true); + assert.equal(rehydrated.taxes.rec.unwrap().equalsTo(rec), true); + assert.equal(rehydrated.taxes.retention.unwrap().equalsTo(retention), true); + + const absent = roundTrip({ + iva: Maybe.some(iva), + rec: Maybe.none(), + retention: Maybe.none(), + }); + assert.equal(absent.persistence.rec_name, null); + assert.equal(absent.persistence.retention_name, null); + assert.equal(absent.rehydrated.taxes.rec.isNone(), true); + assert.equal(absent.rehydrated.taxes.retention.isNone(), true); + + const ivaAndRec = roundTrip({ + iva: Maybe.some(iva), + rec: Maybe.some(rec), + retention: Maybe.none(), + }); + assert.equal(ivaAndRec.rehydrated.taxes.rec.unwrap().equalsTo(rec), true); + assert.equal(ivaAndRec.rehydrated.taxes.retention.isNone(), true); + + const ivaAndRetention = roundTrip({ + iva: Maybe.some(iva), + rec: Maybe.none(), + retention: Maybe.some(retention), + }); + assert.equal(ivaAndRetention.rehydrated.taxes.rec.isNone(), true); + assert.equal(ivaAndRetention.rehydrated.taxes.retention.unwrap().equalsTo(retention), true); +}); + +test("SequelizeProformaV2ItemDomainMapper rechaza metadata fiscal parcial", () => { + const iva = createTax({ + code: "iva_21", + name: "IVA general", + value: 2100, + group: "iva", + calculationBehavior: "additive", + }); + const { persistence } = roundTrip({ + iva: Maybe.some(iva), + rec: Maybe.none(), + retention: Maybe.none(), + }); + const result = new SequelizeProformaV2ItemDomainMapper().mapToDomain( + { ...persistence, iva_name: null } as unknown as ProformaItemModel, + { index: 0, parent: { currencyCode: parent.currencyCode }, errors: [] } + ); + + assert.equal(result.isFailure, true); +}); diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-item-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-item-domain.mapper.ts index 8695e2de..2c83c1d5 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-item-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-item-domain.mapper.ts @@ -89,9 +89,10 @@ export class SequelizeProformaV2ItemDomainMapper extends SequelizeDomainMapper< const iva = extractOrPushError( this.mapTaxToDomain({ code: raw.iva_code, + name: raw.iva_name, + group: raw.iva_group, + calculationBehavior: raw.iva_calculation_behavior, percentageValue: raw.iva_percentage_value, - group: "iva", - calculationBehavior: "additive", fieldPath: `items[${index}].iva`, }), `items[${index}].iva`, @@ -101,9 +102,10 @@ export class SequelizeProformaV2ItemDomainMapper extends SequelizeDomainMapper< const rec = extractOrPushError( this.mapTaxToDomain({ code: raw.rec_code, + name: raw.rec_name, + group: raw.rec_group, + calculationBehavior: raw.rec_calculation_behavior, percentageValue: raw.rec_percentage_value, - group: "surcharge", - calculationBehavior: "additive", fieldPath: `items[${index}].rec`, }), `items[${index}].rec`, @@ -113,9 +115,10 @@ export class SequelizeProformaV2ItemDomainMapper extends SequelizeDomainMapper< const retention = extractOrPushError( this.mapTaxToDomain({ code: raw.retention_code, + name: raw.retention_name, + group: raw.retention_group, + calculationBehavior: raw.retention_calculation_behavior, percentageValue: raw.retention_percentage_value, - group: "retention", - calculationBehavior: "subtractive", fieldPath: `items[${index}].retention`, }), `items[${index}].retention`, @@ -136,12 +139,24 @@ export class SequelizeProformaV2ItemDomainMapper extends SequelizeDomainMapper< private mapTaxToDomain(params: { code: string | null; + name: string | null; percentageValue: number | null; - group: TaxGroup; - calculationBehavior: TaxCalculationBehavior; + group: string | null; + calculationBehavior: string | null; fieldPath: string; }): Result, Error> { if (params.code === null || params.code.trim() === "") { + if ( + params.name !== null || + params.percentageValue !== null || + params.group !== null || + params.calculationBehavior !== null + ) { + return Result.fail( + new Error(`${params.fieldPath} metadata must be absent when tax code is absent`) + ); + } + return Result.ok(Maybe.none()); } @@ -150,6 +165,9 @@ export class SequelizeProformaV2ItemDomainMapper extends SequelizeDomainMapper< new Error(`${params.fieldPath}.percentage_value is required when tax code is present`) ); } + if (!params.name || !params.group || !params.calculationBehavior) { + return Result.fail(new Error(`${params.fieldPath} metadata is required when tax code is present`)); + } const percentageResult = TaxPercentage.create({ value: params.percentageValue, @@ -161,10 +179,10 @@ export class SequelizeProformaV2ItemDomainMapper extends SequelizeDomainMapper< const taxResult = Tax.create({ code: params.code, - name: params.code, + name: params.name, rate: percentageResult.data, - group: params.group, - calculationBehavior: params.calculationBehavior, + group: params.group as TaxGroup, + calculationBehavior: params.calculationBehavior as TaxCalculationBehavior, }); if (taxResult.isFailure) { @@ -263,18 +281,36 @@ export class SequelizeProformaV2ItemDomainMapper extends SequelizeDomainMapper< taxable_amount_value: allAmounts.taxableAmount.value, taxable_amount_scale: allAmounts.taxableAmount.scale, iva_code: maybeToNullable(source.taxes.iva, (value) => value.code), + iva_name: maybeToNullable(source.taxes.iva, (value) => value.name), + iva_group: maybeToNullable(source.taxes.iva, (value) => value.group), + iva_calculation_behavior: maybeToNullable( + source.taxes.iva, + (value) => value.calculationBehavior + ), iva_percentage_value: maybeToNullable(source.taxes.iva, (value) => value.percentage.value), iva_percentage_scale: maybeToNullable(source.taxes.iva, (value) => value.percentage.scale) ?? Tax.DEFAULT_SCALE, iva_amount_value: allAmounts.ivaAmount.value, iva_amount_scale: allAmounts.ivaAmount.scale, rec_code: maybeToNullable(source.taxes.rec, (value) => value.code), + rec_name: maybeToNullable(source.taxes.rec, (value) => value.name), + rec_group: maybeToNullable(source.taxes.rec, (value) => value.group), + rec_calculation_behavior: maybeToNullable( + source.taxes.rec, + (value) => value.calculationBehavior + ), rec_percentage_value: maybeToNullable(source.taxes.rec, (value) => value.percentage.value), rec_percentage_scale: maybeToNullable(source.taxes.rec, (value) => value.percentage.scale) ?? Tax.DEFAULT_SCALE, rec_amount_value: allAmounts.recAmount.value, rec_amount_scale: allAmounts.recAmount.scale, retention_code: maybeToNullable(source.taxes.retention, (value) => value.code), + retention_name: maybeToNullable(source.taxes.retention, (value) => value.name), + retention_group: maybeToNullable(source.taxes.retention, (value) => value.group), + retention_calculation_behavior: maybeToNullable( + source.taxes.retention, + (value) => value.calculationBehavior + ), retention_percentage_value: maybeToNullable( source.taxes.retention, (value) => value.percentage.value diff --git a/modules/customer-invoices/src/common/dto/response/proformas/get-proforma-issuance-readiness.response.dto.ts b/modules/customer-invoices/src/common/dto/response/proformas/get-proforma-issuance-readiness.response.dto.ts index 66be42e3..193b9045 100644 --- a/modules/customer-invoices/src/common/dto/response/proformas/get-proforma-issuance-readiness.response.dto.ts +++ b/modules/customer-invoices/src/common/dto/response/proformas/get-proforma-issuance-readiness.response.dto.ts @@ -11,10 +11,15 @@ export const ProformaIssuanceBlockerCodeSchema = z.enum([ "missing_tax_regime", "payment_method_unavailable", "payment_method_inactive", + "tax_unavailable", ]); export const ProformaIssuanceBlockerSchema = z.object({ code: ProformaIssuanceBlockerCodeSchema, + item_id: z.uuid().optional(), + tax_kind: z.enum(["iva", "rec", "retention"]).optional(), + tax_code: z.string().optional(), + reason: z.enum(["inactive", "out_of_validity", "missing"]).optional(), }); export const GetProformaIssuanceReadinessResponseSchema = z.object({ diff --git a/modules/customer-invoices/src/common/dto/shared/taxes-breakdown.dto.ts b/modules/customer-invoices/src/common/dto/shared/taxes-breakdown.dto.ts index 12b9359b..f360166c 100644 --- a/modules/customer-invoices/src/common/dto/shared/taxes-breakdown.dto.ts +++ b/modules/customer-invoices/src/common/dto/shared/taxes-breakdown.dto.ts @@ -5,14 +5,17 @@ export const TaxesBreakdownSchema = z.object({ taxable_amount: MoneySchema, iva_code: z.string().nullable(), + iva_name: z.string().nullable(), iva_percentage: PercentageSchema.nullable(), iva_amount: MoneySchema, rec_code: z.string().nullable(), + rec_name: z.string().nullable(), rec_percentage: PercentageSchema.nullable(), rec_amount: MoneySchema, retention_code: z.string().nullable(), + retention_name: z.string().nullable(), retention_percentage: PercentageSchema.nullable(), retention_amount: MoneySchema,