feat: enhance proforma tax handling and validation

- Added iva_name, rec_name, and retention_name to the SequelizeIssuedInvoiceV2TaxesDomainMapper for improved tax metadata handling.
- Implemented validation to ensure tax codes and names are present together in the SequelizeIssuedInvoiceV2TaxesDomainMapper.
- Updated proforma services to utilize the new tax resolver and availability checker for better tax management.
- Introduced ProformaTaxesUnavailableError and ProformaNotReadyForIssuanceError for clearer error handling in proforma issuance.
- Enhanced tax availability checking logic in ProformaTaxAvailabilityChecker to classify tax statuses accurately.
- Updated tests to cover new tax metadata requirements and ensure consistent behavior across proforma items.
This commit is contained in:
David Arranz 2026-09-09 23:49:26 +02:00
parent 396feb4fa4
commit 3d8f110cb7
57 changed files with 1334 additions and 55 deletions

View File

@ -113,16 +113,25 @@ CREATE TABLE IF NOT EXISTS proforma_items (
taxable_amount_value BIGINT NOT NULL DEFAULT 0, taxable_amount_value BIGINT NOT NULL DEFAULT 0,
taxable_amount_scale SMALLINT NOT NULL DEFAULT 4, taxable_amount_scale SMALLINT NOT NULL DEFAULT 4,
iva_code VARCHAR(40) NULL, 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_value SMALLINT NULL,
iva_percentage_scale SMALLINT NOT NULL DEFAULT 2, iva_percentage_scale SMALLINT NOT NULL DEFAULT 2,
iva_amount_value BIGINT NOT NULL DEFAULT 0, iva_amount_value BIGINT NOT NULL DEFAULT 0,
iva_amount_scale SMALLINT NOT NULL DEFAULT 4, iva_amount_scale SMALLINT NOT NULL DEFAULT 4,
rec_code VARCHAR(40) NULL, 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_value SMALLINT NULL,
rec_percentage_scale SMALLINT NOT NULL DEFAULT 2, rec_percentage_scale SMALLINT NOT NULL DEFAULT 2,
rec_amount_value BIGINT NOT NULL DEFAULT 0, rec_amount_value BIGINT NOT NULL DEFAULT 0,
rec_amount_scale SMALLINT NOT NULL DEFAULT 4, rec_amount_scale SMALLINT NOT NULL DEFAULT 4,
retention_code VARCHAR(40) NULL, 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_value SMALLINT NULL,
retention_percentage_scale SMALLINT NOT NULL DEFAULT 2, retention_percentage_scale SMALLINT NOT NULL DEFAULT 2,
retention_amount_value BIGINT NOT NULL DEFAULT 0, 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_value BIGINT NOT NULL DEFAULT 0,
taxes_amount_scale SMALLINT NOT NULL DEFAULT 4, taxes_amount_scale SMALLINT NOT NULL DEFAULT 4,
PRIMARY KEY (tax_id), PRIMARY KEY (tax_id),
UNIQUE KEY proforma_iva_code_unique (proforma_id, iva_code),
KEY proforma_id_idx (proforma_id) KEY proforma_id_idx (proforma_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; ) 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_value BIGINT NOT NULL DEFAULT 0,
taxable_amount_scale SMALLINT NOT NULL DEFAULT 4, taxable_amount_scale SMALLINT NOT NULL DEFAULT 4,
iva_code VARCHAR(40) NULL, iva_code VARCHAR(40) NULL,
iva_name VARCHAR(100) NULL,
iva_percentage_value SMALLINT NULL, iva_percentage_value SMALLINT NULL,
iva_percentage_scale SMALLINT NOT NULL DEFAULT 2, iva_percentage_scale SMALLINT NOT NULL DEFAULT 2,
iva_amount_value BIGINT NOT NULL DEFAULT 0, iva_amount_value BIGINT NOT NULL DEFAULT 0,
iva_amount_scale SMALLINT NOT NULL DEFAULT 4, iva_amount_scale SMALLINT NOT NULL DEFAULT 4,
rec_code VARCHAR(40) NULL, rec_code VARCHAR(40) NULL,
rec_name VARCHAR(100) NULL,
rec_percentage_value SMALLINT NULL, rec_percentage_value SMALLINT NULL,
rec_percentage_scale SMALLINT NOT NULL DEFAULT 2, rec_percentage_scale SMALLINT NOT NULL DEFAULT 2,
rec_amount_value BIGINT NOT NULL DEFAULT 0, rec_amount_value BIGINT NOT NULL DEFAULT 0,
rec_amount_scale SMALLINT NOT NULL DEFAULT 4, rec_amount_scale SMALLINT NOT NULL DEFAULT 4,
retention_code VARCHAR(40) NULL, retention_code VARCHAR(40) NULL,
retention_name VARCHAR(100) NULL,
retention_percentage_value SMALLINT NULL, retention_percentage_value SMALLINT NULL,
retention_percentage_scale SMALLINT NOT NULL DEFAULT 2, retention_percentage_scale SMALLINT NOT NULL DEFAULT 2,
retention_amount_value BIGINT NOT NULL DEFAULT 0, 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_value BIGINT NOT NULL DEFAULT 0,
taxable_amount_scale SMALLINT NOT NULL DEFAULT 4, taxable_amount_scale SMALLINT NOT NULL DEFAULT 4,
iva_code VARCHAR(40) NULL, iva_code VARCHAR(40) NULL,
iva_name VARCHAR(100) NULL,
iva_percentage_value SMALLINT NULL, iva_percentage_value SMALLINT NULL,
iva_percentage_scale SMALLINT NOT NULL DEFAULT 2, iva_percentage_scale SMALLINT NOT NULL DEFAULT 2,
iva_amount_value BIGINT NULL, iva_amount_value BIGINT NULL,
iva_amount_scale SMALLINT NOT NULL DEFAULT 4, iva_amount_scale SMALLINT NOT NULL DEFAULT 4,
rec_code VARCHAR(40) NULL, rec_code VARCHAR(40) NULL,
rec_name VARCHAR(100) NULL,
rec_percentage_value SMALLINT NULL, rec_percentage_value SMALLINT NULL,
rec_percentage_scale SMALLINT NOT NULL DEFAULT 2, rec_percentage_scale SMALLINT NOT NULL DEFAULT 2,
rec_amount_value BIGINT NULL, rec_amount_value BIGINT NULL,
rec_amount_scale SMALLINT NOT NULL DEFAULT 4, rec_amount_scale SMALLINT NOT NULL DEFAULT 4,
retention_code VARCHAR(40) NULL, retention_code VARCHAR(40) NULL,
retention_name VARCHAR(100) NULL,
retention_percentage_value SMALLINT NULL, retention_percentage_value SMALLINT NULL,
retention_percentage_scale SMALLINT NOT NULL DEFAULT 2, retention_percentage_scale SMALLINT NOT NULL DEFAULT 2,
retention_amount_value BIGINT NULL, 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_value BIGINT NOT NULL DEFAULT 0,
taxes_amount_scale SMALLINT NOT NULL DEFAULT 4, taxes_amount_scale SMALLINT NOT NULL DEFAULT 4,
PRIMARY KEY (tax_id), PRIMARY KEY (tax_id),
UNIQUE KEY issued_invoice_iva_code_unique (issued_invoice_id, iva_code),
KEY issued_invoice_id_idx (issued_invoice_id) KEY issued_invoice_id_idx (issued_invoice_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;

View File

@ -17,6 +17,12 @@ export interface FindActiveTaxDefinitionsByCodesParams {
transaction?: unknown; transaction?: unknown;
} }
export interface FindTaxDefinitionsByCodesParams {
companyId: UniqueID;
codes: Collection<string>;
transaction?: unknown;
}
export interface ITaxDefinitionPublicFinder { export interface ITaxDefinitionPublicFinder {
findActiveByCode( findActiveByCode(
params: FindActiveTaxDefinitionByCodeParams params: FindActiveTaxDefinitionByCodeParams
@ -26,6 +32,10 @@ export interface ITaxDefinitionPublicFinder {
params: FindActiveTaxDefinitionsByCodesParams params: FindActiveTaxDefinitionsByCodesParams
): Promise<Result<Collection<TaxDefinitionPublicModel>, Error>>; ): Promise<Result<Collection<TaxDefinitionPublicModel>, Error>>;
findByCodes(
params: FindTaxDefinitionsByCodesParams
): Promise<Result<Collection<TaxDefinitionPublicModel>, Error>>;
ensureActiveByCodes( ensureActiveByCodes(
params: FindActiveTaxDefinitionsByCodesParams params: FindActiveTaxDefinitionsByCodesParams
): Promise<Result<Collection<TaxDefinitionPublicModel>, Error>>; ): Promise<Result<Collection<TaxDefinitionPublicModel>, Error>>;

View File

@ -6,6 +6,7 @@ import type { TaxDefinitionPublicModelMapper } from "../mappers/tax-definition-p
import type { import type {
FindActiveTaxDefinitionByCodeParams, FindActiveTaxDefinitionByCodeParams,
FindActiveTaxDefinitionsByCodesParams, FindActiveTaxDefinitionsByCodesParams,
FindTaxDefinitionsByCodesParams,
ITaxDefinitionPublicFinder, ITaxDefinitionPublicFinder,
} from "./tax-definition-public-finder.interface"; } from "./tax-definition-public-finder.interface";
import { TaxDefinitionPublicModel } from '../models'; 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)))); return Result.ok(new Collection(result.data.map((taxDefinition) => this.deps.mapper.toPublicModel(taxDefinition))));
} }
public async findByCodes(
params: FindTaxDefinitionsByCodesParams
): Promise<Result<Collection<TaxDefinitionPublicModel>, 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<Result<Collection<TaxDefinitionPublicModel>, Error>> { public async ensureActiveByCodes(params: FindActiveTaxDefinitionsByCodesParams):Promise<Result<Collection<TaxDefinitionPublicModel>, Error>> {
const result = await this.findActiveByCodes(params); const result = await this.findActiveByCodes(params);

View File

@ -46,6 +46,12 @@ export interface ITaxDefinitionRepository {
transaction?: unknown; transaction?: unknown;
}): Promise<Result<Collection<TaxDefinition>, Error>>; }): Promise<Result<Collection<TaxDefinition>, Error>>;
findByCodesInCompany(params: {
companyId: UniqueID;
codes: Collection<string>;
transaction?: unknown;
}): Promise<Result<Collection<TaxDefinition>, Error>>;
findByCriteriaInCompany( findByCriteriaInCompany(
companyId: UniqueID, companyId: UniqueID,
criteria: Criteria, criteria: Criteria,

View File

@ -179,6 +179,34 @@ export class SequelizeTaxDefinitionRepository
} }
} }
async findByCodesInCompany(params: {
companyId: UniqueID;
codes: Collection<string>;
transaction?: Transaction;
}): Promise<Result<Collection<TaxDefinition>, 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( async findByCriteriaInCompany(
companyId: UniqueID, companyId: UniqueID,
criteria: Criteria, criteria: Criteria,

View File

@ -1,5 +1,5 @@
import type { IDocumentMetadata } from "../application-models"; import type { IDocumentMetadata } from "../application-models";
export interface IDocumentMetadataFactory<TSnapshot> { export interface IDocumentMetadataFactory<TSnapshot, TSnapshotContext = Record<string, string>> {
build(snapshot: TSnapshot): IDocumentMetadata; build(snapshot: TSnapshot, context: TSnapshotContext): IDocumentMetadata;
} }

View File

@ -66,12 +66,15 @@ const createIssuedInvoiceFixture = (withOptionals = true): IssuedInvoice => {
totalDiscountAmount: amount(643), totalDiscountAmount: amount(643),
taxableAmount: amount(3107), taxableAmount: amount(3107),
ivaCode: Maybe.some("iva_21"), ivaCode: Maybe.some("iva_21"),
ivaName: Maybe.some("IVA general"),
ivaPercentage: Maybe.some(iva), ivaPercentage: Maybe.some(iva),
ivaAmount: amount(652), ivaAmount: amount(652),
recCode: Maybe.some("rec_52"), recCode: Maybe.some("rec_52"),
recName: Maybe.some("Recargo de equivalencia"),
recPercentage: Maybe.some(rec), recPercentage: Maybe.some(rec),
recAmount: amount(162), recAmount: amount(162),
retentionCode: Maybe.some("ret_15"), retentionCode: Maybe.some("ret_15"),
retentionName: Maybe.some("Retención IRPF"),
retentionPercentage: Maybe.some(retention), retentionPercentage: Maybe.some(retention),
retentionAmount: amount(466), retentionAmount: amount(466),
taxesAmount: amount(348), taxesAmount: amount(348),
@ -105,12 +108,15 @@ const createIssuedInvoiceFixture = (withOptionals = true): IssuedInvoice => {
IssuedInvoiceTax.create({ IssuedInvoiceTax.create({
taxableAmount: total(6214), taxableAmount: total(6214),
ivaCode: "iva_21", ivaCode: "iva_21",
ivaName: "IVA general",
ivaPercentage: iva, ivaPercentage: iva,
ivaAmount: total(1305), ivaAmount: total(1305),
recCode: Maybe.some("rec_52"), recCode: Maybe.some("rec_52"),
recName: Maybe.some("Recargo de equivalencia"),
recPercentage: Maybe.some(rec), recPercentage: Maybe.some(rec),
recAmount: total(323), recAmount: total(323),
retentionCode: Maybe.some("ret_15"), retentionCode: Maybe.some("ret_15"),
retentionName: Maybe.some("Retención IRPF"),
retentionPercentage: Maybe.some(retention), retentionPercentage: Maybe.some(retention),
retentionAmount: total(932), retentionAmount: total(932),
taxesAmount: total(696), taxesAmount: total(696),

View File

@ -1,10 +1,11 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; 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 { CurrencyCode, LanguageCode, UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils"; import { Maybe, Result } from "@repo/rdx-utils";
import { InvoiceAmount } from "../../../domain";
import { ProformaToIssuedInvoiceConverter } from "./proforma-to-issued-invoice-props-converter"; import { ProformaToIssuedInvoiceConverter } from "./proforma-to-issued-invoice-props-converter";
test("toCreateProps no materializa VerifactuRecord ni policy en el 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);
});

View File

@ -208,14 +208,17 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
totalDiscountAmount: itemTotals.totalDiscountAmount, totalDiscountAmount: itemTotals.totalDiscountAmount,
ivaCode: item.ivaCode(), ivaCode: item.ivaCode(),
ivaName: item.taxes.iva.map((tax) => tax.name),
ivaPercentage: item.ivaPercentage(), ivaPercentage: item.ivaPercentage(),
ivaAmount: itemTotals.ivaAmount, ivaAmount: itemTotals.ivaAmount,
recCode: item.recCode(), recCode: item.recCode(),
recName: item.taxes.rec.map((tax) => tax.name),
recPercentage: item.recPercentage(), recPercentage: item.recPercentage(),
recAmount: itemTotals.recAmount, recAmount: itemTotals.recAmount,
retentionCode: item.retentionCode(), retentionCode: item.retentionCode(),
retentionName: item.taxes.retention.map((tax) => tax.name),
retentionPercentage: item.retentionPercentage(), retentionPercentage: item.retentionPercentage(),
retentionAmount: itemTotals.retentionAmount, retentionAmount: itemTotals.retentionAmount,
@ -245,21 +248,24 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
private resolveTax( private resolveTax(
tax: ReturnType<ReturnType<Proforma["taxes"]>["getAll"]>[number] tax: ReturnType<ReturnType<Proforma["taxes"]>["getAll"]>[number]
): Result<IssuedInvoiceTax, Error> { ): Result<IssuedInvoiceTax, Error> {
if (tax.ivaCode.isNone()) { if (tax.ivaCode.isNone() || tax.ivaName.isNone()) {
return Result.fail(new Error("IVA code is required")); return Result.fail(new Error("IVA code and name are required"));
} }
return IssuedInvoiceTax.create({ return IssuedInvoiceTax.create({
taxableAmount: tax.taxableAmount, taxableAmount: tax.taxableAmount,
ivaCode: tax.ivaCode.unwrap(), ivaCode: tax.ivaCode.unwrap(),
ivaName: tax.ivaName.unwrap(),
ivaPercentage: tax.ivaPercentage.unwrap(), ivaPercentage: tax.ivaPercentage.unwrap(),
ivaAmount: tax.ivaAmount, ivaAmount: tax.ivaAmount,
recCode: tax.recCode, recCode: tax.recCode,
recName: tax.recName,
recPercentage: tax.recPercentage, recPercentage: tax.recPercentage,
recAmount: tax.recAmount, recAmount: tax.recAmount,
retentionCode: tax.retentionCode, retentionCode: tax.retentionCode,
retentionName: tax.retentionName,
retentionPercentage: tax.retentionPercentage, retentionPercentage: tax.retentionPercentage,
retentionAmount: tax.retentionAmount, retentionAmount: tax.retentionAmount,

View File

@ -20,14 +20,17 @@ export interface IIssuedInvoiceItemFullSnapshot {
taxable_amount: { value: string; scale: string; currency_code: string }; taxable_amount: { value: string; scale: string; currency_code: string };
iva_code: string; iva_code: string;
iva_name: string;
iva_percentage: { value: string; scale: string }; iva_percentage: { value: string; scale: string };
iva_amount: { value: string; scale: string; currency_code: string }; iva_amount: { value: string; scale: string; currency_code: string };
rec_code: string; rec_code: string;
rec_name: string;
rec_percentage: { value: string; scale: string }; rec_percentage: { value: string; scale: string };
rec_amount: { value: string; scale: string; currency_code: string }; rec_amount: { value: string; scale: string; currency_code: string };
retention_code: string; retention_code: string;
retention_name: string;
retention_percentage: { value: string; scale: string }; retention_percentage: { value: string; scale: string };
retention_amount: { value: string; scale: string; currency_code: string }; retention_amount: { value: string; scale: string; currency_code: string };

View File

@ -68,14 +68,17 @@ export class IssuedInvoiceItemsFullSnapshotBuilder
: emptyMoneyObject, : emptyMoneyObject,
iva_code: maybeToEmptyString(invoiceItem.ivaCode), iva_code: maybeToEmptyString(invoiceItem.ivaCode),
iva_name: maybeToEmptyString(invoiceItem.ivaName),
iva_percentage: maybeToEmptyPercentageObjectString(invoiceItem.ivaPercentage), iva_percentage: maybeToEmptyPercentageObjectString(invoiceItem.ivaPercentage),
iva_amount: isValued ? invoiceItem.ivaAmount.toObjectString() : emptyMoneyObject, iva_amount: isValued ? invoiceItem.ivaAmount.toObjectString() : emptyMoneyObject,
rec_code: maybeToEmptyString(invoiceItem.recCode), rec_code: maybeToEmptyString(invoiceItem.recCode),
rec_name: maybeToEmptyString(invoiceItem.recName),
rec_percentage: maybeToEmptyPercentageObjectString(invoiceItem.recPercentage), rec_percentage: maybeToEmptyPercentageObjectString(invoiceItem.recPercentage),
rec_amount: isValued ? invoiceItem.recAmount.toObjectString() : emptyMoneyObject, rec_amount: isValued ? invoiceItem.recAmount.toObjectString() : emptyMoneyObject,
retention_code: maybeToEmptyString(invoiceItem.retentionCode), retention_code: maybeToEmptyString(invoiceItem.retentionCode),
retention_name: maybeToEmptyString(invoiceItem.retentionName),
retention_percentage: maybeToEmptyPercentageObjectString(invoiceItem.retentionPercentage), retention_percentage: maybeToEmptyPercentageObjectString(invoiceItem.retentionPercentage),
retention_amount: isValued retention_amount: isValued
? invoiceItem.retentionAmount.toObjectString() ? invoiceItem.retentionAmount.toObjectString()

View File

@ -2,14 +2,17 @@ export interface IIssuedInvoiceTaxFullSnapshot {
taxable_amount: { value: string; scale: string; currency_code: string }; taxable_amount: { value: string; scale: string; currency_code: string };
iva_code: string; iva_code: string;
iva_name: string;
iva_percentage: { value: string; scale: string }; iva_percentage: { value: string; scale: string };
iva_amount: { value: string; scale: string; currency_code: string }; iva_amount: { value: string; scale: string; currency_code: string };
rec_code: string | null; rec_code: string | null;
rec_name: string | null;
rec_percentage: { value: string; scale: string }; rec_percentage: { value: string; scale: string };
rec_amount: { value: string; scale: string; currency_code: string }; rec_amount: { value: string; scale: string; currency_code: string };
retention_code: string | null; retention_code: string | null;
retention_name: string | null;
retention_percentage: { value: string; scale: string }; retention_percentage: { value: string; scale: string };
retention_amount: { value: string; scale: string; currency_code: string }; retention_amount: { value: string; scale: string; currency_code: string };

View File

@ -16,14 +16,17 @@ export class IssuedInvoiceTaxesFullSnapshotBuilder
taxable_amount: invoiceTax.taxableAmount.toObjectString(), taxable_amount: invoiceTax.taxableAmount.toObjectString(),
iva_code: invoiceTax.ivaCode.toString(), iva_code: invoiceTax.ivaCode.toString(),
iva_name: invoiceTax.ivaName,
iva_percentage: invoiceTax.ivaPercentage.toObjectString(), iva_percentage: invoiceTax.ivaPercentage.toObjectString(),
iva_amount: invoiceTax.ivaAmount.toObjectString(), iva_amount: invoiceTax.ivaAmount.toObjectString(),
rec_code: maybeToNullable(invoiceTax.recCode, (v) => v.toString()), rec_code: maybeToNullable(invoiceTax.recCode, (v) => v.toString()),
rec_name: maybeToNullable(invoiceTax.recName, (v) => v.toString()),
rec_percentage: maybeToEmptyPercentageObjectString(invoiceTax.recPercentage), rec_percentage: maybeToEmptyPercentageObjectString(invoiceTax.recPercentage),
rec_amount: invoiceTax.recAmount.toObjectString(), rec_amount: invoiceTax.recAmount.toObjectString(),
retention_code: maybeToNullable(invoiceTax.retentionCode, (v) => v.toString()), retention_code: maybeToNullable(invoiceTax.retentionCode, (v) => v.toString()),
retention_name: maybeToNullable(invoiceTax.retentionName, (v) => v.toString()),
retention_percentage: maybeToEmptyPercentageObjectString(invoiceTax.retentionPercentage), retention_percentage: maybeToEmptyPercentageObjectString(invoiceTax.retentionPercentage),
retention_amount: invoiceTax.retentionAmount.toObjectString(), retention_amount: invoiceTax.retentionAmount.toObjectString(),

View File

@ -26,14 +26,17 @@ export class IssuedInvoiceReportTaxSnapshotBuilder
taxable_amount: MoneyDTOHelper.format(tax.taxable_amount, locale, moneyOptions), taxable_amount: MoneyDTOHelper.format(tax.taxable_amount, locale, moneyOptions),
iva_code: tax.iva_code, iva_code: tax.iva_code,
iva_name: tax.iva_name,
iva_percentage: PercentageDTOHelper.format(tax.iva_percentage, locale), iva_percentage: PercentageDTOHelper.format(tax.iva_percentage, locale),
iva_amount: MoneyDTOHelper.format(tax.iva_amount, locale, moneyOptions), iva_amount: MoneyDTOHelper.format(tax.iva_amount, locale, moneyOptions),
rec_code: tax.rec_code ?? "", rec_code: tax.rec_code ?? "",
rec_name: tax.rec_name ?? "",
rec_percentage: PercentageDTOHelper.format(tax.rec_percentage, locale), rec_percentage: PercentageDTOHelper.format(tax.rec_percentage, locale),
rec_amount: MoneyDTOHelper.format(tax.rec_amount, locale, moneyOptions), rec_amount: MoneyDTOHelper.format(tax.rec_amount, locale, moneyOptions),
retention_code: tax.retention_code ?? "", retention_code: tax.retention_code ?? "",
retention_name: tax.retention_name ?? "",
retention_percentage: PercentageDTOHelper.format(tax.retention_percentage, locale), retention_percentage: PercentageDTOHelper.format(tax.retention_percentage, locale),
retention_amount: MoneyDTOHelper.format(tax.retention_amount, locale, moneyOptions), retention_amount: MoneyDTOHelper.format(tax.retention_amount, locale, moneyOptions),

View File

@ -2,14 +2,17 @@ export interface IIssuedInvoiceReportTaxSnapshot {
taxable_amount: string; taxable_amount: string;
iva_code: string; iva_code: string;
iva_name: string;
iva_percentage: string; iva_percentage: string;
iva_amount: string; iva_amount: string;
rec_code: string; rec_code: string;
rec_name: string;
rec_percentage: string; rec_percentage: string;
rec_amount: string; rec_amount: string;
retention_code: string; retention_code: string;
retention_name: string;
retention_percentage: string; retention_percentage: string;
retention_amount: string; retention_amount: string;

View File

@ -1,6 +1,9 @@
import type { IProformaRepository } from "../repositories"; 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 { export function buildProformaFinder(params: {
return new ProformaFinder(repository); repository: IProformaRepository;
taxResolver: ProformaTaxResolver;
}): IProformaFinder {
return new ProformaFinder(params);
} }

View File

@ -1,10 +1,15 @@
import type { IAudit } from "@repo/rdx-audit"; import type { IAudit } from "@repo/rdx-audit";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
import { type IProformaStatusChanger, ProformaStatusChanger } from "../services"; import {
type IProformaStatusChanger,
type ProformaTaxAvailabilityChecker,
ProformaStatusChanger,
} from "../services";
export const buildProformaStatusChanger = (params: { export const buildProformaStatusChanger = (params: {
repository: IProformaRepository; repository: IProformaRepository;
taxAvailabilityChecker: ProformaTaxAvailabilityChecker;
audit: IAudit; audit: IAudit;
}): IProformaStatusChanger => { }): IProformaStatusChanger => {
return new ProformaStatusChanger(params); return new ProformaStatusChanger(params);

View File

@ -161,6 +161,7 @@ export function buildIssueProformaUseCase(deps: {
issuedInvoiceServices: IIssuedInvoicePublicServices; issuedInvoiceServices: IIssuedInvoicePublicServices;
}; };
finder: IProformaFinder; finder: IProformaFinder;
readinessChecker: IProformaIssuanceReadinessChecker;
issuer: IProformaIssuer; issuer: IProformaIssuer;
issueReadModelAssembler: IProformaIssueReadModelAssembler; issueReadModelAssembler: IProformaIssueReadModelAssembler;
issueResponseSnapshotBuilder: IIssueProformaResponseSnapshotBuilder; issueResponseSnapshotBuilder: IIssueProformaResponseSnapshotBuilder;
@ -170,6 +171,7 @@ export function buildIssueProformaUseCase(deps: {
}) { }) {
const { const {
finder, finder,
readinessChecker,
issuer, issuer,
issueReadModelAssembler, issueReadModelAssembler,
issueResponseSnapshotBuilder, issueResponseSnapshotBuilder,
@ -182,6 +184,7 @@ export function buildIssueProformaUseCase(deps: {
return new IssueProformaUseCase({ return new IssueProformaUseCase({
issuedInvoiceServices, issuedInvoiceServices,
finder, finder,
readinessChecker,
issuer, issuer,
issueReadModelAssembler, issueReadModelAssembler,
issueResponseSnapshotBuilder, issueResponseSnapshotBuilder,

View File

@ -1 +1,3 @@
export * from "./company-report-profile-not-found.error"; export * from "./company-report-profile-not-found.error";
export * from "./proforma-taxes-unavailable.error";
export * from "./proforma-not-ready-for-issuance.error";

View File

@ -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;

View File

@ -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;

View File

@ -4,5 +4,9 @@ export interface ProformaIssuanceReadinessModel {
canIssue: boolean; canIssue: boolean;
blockers: Array<{ blockers: Array<{
code: GetProformaIssuanceReadinessResponseDTO["blockers"][number]["code"]; code: GetProformaIssuanceReadinessResponseDTO["blockers"][number]["code"];
itemId?: string;
taxKind?: "iva" | "rec" | "retention";
taxCode?: string;
reason?: "inactive" | "out_of_validity" | "missing";
}>; }>;
} }

View File

@ -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 = <T>(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);
});

View File

@ -31,6 +31,12 @@ export interface FindActiveProformaTaxDefinitionByCodeParams {
transaction?: unknown; transaction?: unknown;
} }
export interface ResolveExistingProformaItemTaxesParams {
companyId: UniqueID;
itemsTaxes: Collection<ProformaItemTaxesProps>;
transaction?: unknown;
}
/** /**
* Resuelve datos fiscales externos al agregado de proforma usando servicios públicos de `catalogs`. * 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<Result<Collection<ProformaItemTaxesProps>, 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<string, Tax>();
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<string> { private collectCodes(taxCodes: ProformaItemTaxCodesInput): Collection<string> {
const codes = [taxCodes.ivaCode, taxCodes.recCode, taxCodes.retentionCode] const codes = [taxCodes.ivaCode, taxCodes.recCode, taxCodes.retentionCode]
.filter((code) => code.isSome()) .filter((code) => code.isSome())
@ -167,4 +221,13 @@ export class ProformaTaxResolver {
return Result.ok(Maybe.some(tax.data)); return Result.ok(Maybe.some(tax.data));
} }
private replaceWithLiveTax(tax: Maybe<Tax>, liveTaxesByCode: Map<string, Tax>): Maybe<Tax> {
if (tax.isNone()) {
return tax;
}
const liveTax = liveTaxesByCode.get(tax.unwrap().code);
return liveTax ? Maybe.some(liveTax) : tax;
}
} }

View File

@ -15,4 +15,5 @@ export * from "./proforma-public-services.interface";
export * from "./proforma-series-validator.interface"; export * from "./proforma-series-validator.interface";
export * from "./proforma-status-changer"; export * from "./proforma-status-changer";
export * from "./proforma-target-invoice-series-validator.interface"; export * from "./proforma-target-invoice-series-validator.interface";
export * from "./proforma-tax-availability-checker";
export * from "./proforma-updater"; export * from "./proforma-updater";

View File

@ -6,6 +6,7 @@ import type { Proforma } from "../../../domain";
import type { ProformaStatusCounts, ProformaSummary } from "../models"; import type { ProformaStatusCounts, ProformaSummary } from "../models";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
import type { ProformaListScope } from "../use-cases"; import type { ProformaListScope } from "../use-cases";
import type { ProformaTaxResolver } from "./catalog-resolver";
export interface IProformaFinder { export interface IProformaFinder {
findProformaById( findProformaById(
@ -42,14 +43,20 @@ export interface IProformaFinder {
} }
export class ProformaFinder implements IProformaFinder { export class ProformaFinder implements IProformaFinder {
constructor(private readonly repository: IProformaRepository) {} constructor(
private readonly deps: {
repository: IProformaRepository;
taxResolver: ProformaTaxResolver;
}
) {}
async findProformaById( async findProformaById(
companyId: UniqueID, companyId: UniqueID,
proformaId: UniqueID, proformaId: UniqueID,
transaction?: unknown transaction?: unknown
): Promise<Result<Proforma, Error>> { ): Promise<Result<Proforma, Error>> {
return this.repository.getByIdInCompany(companyId, proformaId, transaction); const result = await this.deps.repository.getByIdInCompany(companyId, proformaId, transaction);
return this.resolveLiveTaxes(result, transaction);
} }
async findProformaByIdIncludingArchived( async findProformaByIdIncludingArchived(
@ -57,7 +64,12 @@ export class ProformaFinder implements IProformaFinder {
proformaId: UniqueID, proformaId: UniqueID,
transaction?: unknown transaction?: unknown
): Promise<Result<Proforma, Error>> { ): Promise<Result<Proforma, Error>> {
return this.repository.getByIdIncludingArchivedInCompany(companyId, proformaId, transaction); const result = await this.deps.repository.getByIdIncludingArchivedInCompany(
companyId,
proformaId,
transaction
);
return this.resolveLiveTaxes(result, transaction);
} }
async proformaExists( async proformaExists(
@ -65,7 +77,7 @@ export class ProformaFinder implements IProformaFinder {
proformaId: UniqueID, proformaId: UniqueID,
transaction?: unknown transaction?: unknown
): Promise<Result<boolean, Error>> { ): Promise<Result<boolean, Error>> {
return this.repository.existsByIdInCompany(companyId, proformaId, transaction); return this.deps.repository.existsByIdInCompany(companyId, proformaId, transaction);
} }
async findProformasByCriteria( async findProformasByCriteria(
@ -74,7 +86,7 @@ export class ProformaFinder implements IProformaFinder {
criteria: Criteria, criteria: Criteria,
transaction?: unknown transaction?: unknown
): Promise<Result<Collection<ProformaSummary>, Error>> { ): Promise<Result<Collection<ProformaSummary>, Error>> {
return this.repository.findByCriteriaInCompany(companyId, scope, criteria, transaction); return this.deps.repository.findByCriteriaInCompany(companyId, scope, criteria, transaction);
} }
async countProformasByStatus( async countProformasByStatus(
@ -83,6 +95,28 @@ export class ProformaFinder implements IProformaFinder {
criteria: Criteria, criteria: Criteria,
transaction?: unknown transaction?: unknown
): Promise<Result<ProformaStatusCounts, Error>> { ): Promise<Result<ProformaStatusCounts, Error>> {
return this.repository.countByStatusInCompany(companyId, scope, criteria, transaction); return this.deps.repository.countByStatusInCompany(companyId, scope, criteria, transaction);
}
private async resolveLiveTaxes(
result: Result<Proforma, Error>,
transaction?: unknown
): Promise<Result<Proforma, Error>> {
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);
} }
} }

View File

@ -3,7 +3,7 @@ import test from "node:test";
import { DiscountPercentage } from "@erp/core/api"; import { DiscountPercentage } from "@erp/core/api";
import { CurrencyCode, LanguageCode, UniqueID, UtcDate } from "@repo/rdx-ddd"; 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 { import {
InvoiceNumber, InvoiceNumber,
@ -89,6 +89,7 @@ const buildApprovedProforma = (params?: {
test("check devuelve canIssue true cuando la proforma y el payment method son válidos", async () => { test("check devuelve canIssue true cuando la proforma y el payment method son válidos", async () => {
const checker = new ProformaIssuanceReadinessChecker({ const checker = new ProformaIssuanceReadinessChecker({
taxAvailabilityChecker: { checkProformaTaxes: async () => Result.ok(new Collection([])) } as never,
paymentMethodFinder: { paymentMethodFinder: {
findByIdInCompany: async () => findByIdInCompany: async () =>
Result.ok( 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 () => { test("check acumula blockers internos sin abortar en el primero", async () => {
const checker = new ProformaIssuanceReadinessChecker({ const checker = new ProformaIssuanceReadinessChecker({
taxAvailabilityChecker: { checkProformaTaxes: async () => Result.ok(new Collection([])) } as never,
paymentMethodFinder: { paymentMethodFinder: {
findByIdInCompany: async () => Result.ok(Maybe.none()), findByIdInCompany: async () => Result.ok(Maybe.none()),
} as never, } 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 () => { test("check informa payment_method_unavailable cuando el finder no resuelve la referencia company-scoped", async () => {
const checker = new ProformaIssuanceReadinessChecker({ const checker = new ProformaIssuanceReadinessChecker({
taxAvailabilityChecker: { checkProformaTaxes: async () => Result.ok(new Collection([])) } as never,
paymentMethodFinder: { paymentMethodFinder: {
findByIdInCompany: async () => Result.ok(Maybe.none()), findByIdInCompany: async () => Result.ok(Maybe.none()),
} as never, } 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 () => { test("check informa payment_method_inactive cuando la referencia resuelta está inactiva", async () => {
const checker = new ProformaIssuanceReadinessChecker({ const checker = new ProformaIssuanceReadinessChecker({
taxAvailabilityChecker: { checkProformaTaxes: async () => Result.ok(new Collection([])) } as never,
paymentMethodFinder: { paymentMethodFinder: {
findByIdInCompany: async () => findByIdInCompany: async () =>
Result.ok( Result.ok(

View File

@ -4,6 +4,7 @@ import { Result } from "@repo/rdx-utils";
import type { Proforma } from "../../../domain"; import type { Proforma } from "../../../domain";
import type { ProformaIssuanceReadinessModel } from "../models"; import type { ProformaIssuanceReadinessModel } from "../models";
import type { ProformaTaxAvailabilityChecker } from "./proforma-tax-availability-checker";
export interface IProformaIssuanceReadinessChecker { export interface IProformaIssuanceReadinessChecker {
check(params: { check(params: {
@ -17,6 +18,7 @@ export class ProformaIssuanceReadinessChecker implements IProformaIssuanceReadin
public constructor( public constructor(
private readonly deps: { private readonly deps: {
paymentMethodFinder: IPaymentMethodPublicFinder; 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({ return Result.ok({
canIssue: blockers.length === 0, canIssue: blockers.length === 0,
blockers, blockers,

View File

@ -7,10 +7,11 @@ import type {
RecordAuditInput, RecordAuditInput,
RecordAuditMutationShortcutInput, RecordAuditMutationShortcutInput,
} from "@repo/rdx-audit"; } from "@repo/rdx-audit";
import { Result } from "@repo/rdx-utils"; import { Collection, Result } from "@repo/rdx-utils";
import { InvoiceStatus } from "../../../domain"; import { InvoiceStatus } from "../../../domain";
import { createProformaTestFixture } from "../../../domain/proformas/testing/proforma-test-fixture"; import { createProformaTestFixture } from "../../../domain/proformas/testing/proforma-test-fixture";
import { ProformaTaxesUnavailableError } from "../errors";
import { ProformaStatusChanger } from "./proforma-status-changer"; import { ProformaStatusChanger } from "./proforma-status-changer";
@ -45,6 +46,9 @@ test("cambia draft a sent y registra proforma.status_changed", async () => {
return Result.ok(); return Result.ok();
}, },
} as never, } as never,
taxAvailabilityChecker: {
checkProformaTaxes: async () => Result.ok(new Collection([])),
} as never,
audit, audit,
}); });
@ -82,6 +86,9 @@ test("no persiste ni audita draft a draft", async () => {
return Result.ok(); return Result.ok();
}, },
} as never, } as never,
taxAvailabilityChecker: {
checkProformaTaxes: async () => Result.ok(new Collection([])),
} as never,
audit, audit,
}); });
@ -110,6 +117,9 @@ test("rechaza una transición manual inválida sin persistir ni auditar", async
return Result.ok(); return Result.ok();
}, },
} as never, } as never,
taxAvailabilityChecker: {
checkProformaTaxes: async () => Result.ok(new Collection([])),
} as never,
audit, audit,
}); });
@ -123,3 +133,50 @@ test("rechaza una transición manual inválida sin persistir ni auditar", async
assert.equal(updateCalls, 0); assert.equal(updateCalls, 0);
assert.equal(audit.recordMutationCalls.length, 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);
});

View File

@ -8,7 +8,9 @@ import {
toProformaAuditState, toProformaAuditState,
toProformaAuditTarget, toProformaAuditTarget,
} from "../audit/proforma-audit"; } from "../audit/proforma-audit";
import { ProformaTaxesUnavailableError } from "../errors";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
import type { ProformaTaxAvailabilityChecker } from "./proforma-tax-availability-checker";
export interface IProformaStatusChanger { export interface IProformaStatusChanger {
changeStatus(params: { changeStatus(params: {
@ -23,6 +25,7 @@ export class ProformaStatusChanger implements IProformaStatusChanger {
public constructor( public constructor(
private readonly deps: { private readonly deps: {
repository: IProformaRepository; repository: IProformaRepository;
taxAvailabilityChecker: ProformaTaxAvailabilityChecker;
audit: IAudit; audit: IAudit;
} }
) {} ) {}
@ -50,8 +53,34 @@ export class ProformaStatusChanger implements IProformaStatusChanger {
} }
const proforma = proformaResult.data; 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); const changeResult = proforma.changeStatus(statusResult.data);
if (changeResult.isFailure) { if (changeResult.isFailure) {

View File

@ -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<Result<Collection<ProformaTaxAvailabilityEntry>, 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";
}
}

View File

@ -53,6 +53,7 @@ export class ProformaItemsFullSnapshotBuilder implements IProformaItemsFullSnaps
: ItemAmount.zero(currencyCode).toObjectString(), : ItemAmount.zero(currencyCode).toObjectString(),
iva_code: maybeToNullable(proformaItem.ivaCode(), (value) => value.toString()), iva_code: maybeToNullable(proformaItem.ivaCode(), (value) => value.toString()),
iva_name: maybeToNullable(proformaItem.taxes.iva, (tax) => tax.name),
iva_percentage: maybeToNullable(proformaItem.ivaPercentage(), (value) => iva_percentage: maybeToNullable(proformaItem.ivaPercentage(), (value) =>
value.toObjectString() value.toObjectString()
), ),
@ -61,6 +62,7 @@ export class ProformaItemsFullSnapshotBuilder implements IProformaItemsFullSnaps
: ItemAmount.zero(currencyCode).toObjectString(), : ItemAmount.zero(currencyCode).toObjectString(),
rec_code: maybeToNullable(proformaItem.recCode(), (value) => value.toString()), rec_code: maybeToNullable(proformaItem.recCode(), (value) => value.toString()),
rec_name: maybeToNullable(proformaItem.taxes.rec, (tax) => tax.name),
rec_percentage: maybeToNullable(proformaItem.recPercentage(), (value) => rec_percentage: maybeToNullable(proformaItem.recPercentage(), (value) =>
value.toObjectString() value.toObjectString()
), ),
@ -69,6 +71,7 @@ export class ProformaItemsFullSnapshotBuilder implements IProformaItemsFullSnaps
: ItemAmount.zero(currencyCode).toObjectString(), : ItemAmount.zero(currencyCode).toObjectString(),
retention_code: maybeToNullable(proformaItem.retentionCode(), (value) => value.toString()), retention_code: maybeToNullable(proformaItem.retentionCode(), (value) => value.toString()),
retention_name: maybeToNullable(proformaItem.taxes.retention, (tax) => tax.name),
retention_percentage: maybeToNullable(proformaItem.retentionPercentage(), (value) => retention_percentage: maybeToNullable(proformaItem.retentionPercentage(), (value) =>
value.toObjectString() value.toObjectString()
), ),

View File

@ -14,14 +14,17 @@ export class ProformaTaxesFullSnapshotBuilder implements IProformaTaxesFullSnaps
taxable_amount: proformaTax.taxableAmount.toObjectString(), taxable_amount: proformaTax.taxableAmount.toObjectString(),
iva_code: maybeToNullable(proformaTax.ivaCode, (value) => value.toString()), 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_percentage: maybeToNullable(proformaTax.ivaPercentage, (value) => value.toObjectString()),
iva_amount: proformaTax.ivaAmount.toObjectString(), iva_amount: proformaTax.ivaAmount.toObjectString(),
rec_code: maybeToNullable(proformaTax.recCode, (value) => value.toString()), 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_percentage: maybeToNullable(proformaTax.recPercentage, (value) => value.toObjectString()),
rec_amount: proformaTax.recAmount.toObjectString(), rec_amount: proformaTax.recAmount.toObjectString(),
retention_code: maybeToNullable(proformaTax.retentionCode, (value) => value.toString()), retention_code: maybeToNullable(proformaTax.retentionCode, (value) => value.toString()),
retention_name: maybeToNullable(proformaTax.retentionName, (value) => value.toString()),
retention_percentage: maybeToNullable(proformaTax.retentionPercentage, (value) => retention_percentage: maybeToNullable(proformaTax.retentionPercentage, (value) =>
value.toObjectString() value.toObjectString()
), ),

View File

@ -16,6 +16,10 @@ export class ProformaIssuanceReadinessSnapshotBuilder
can_issue: source.canIssue, can_issue: source.canIssue,
blockers: source.blockers.map((blocker) => ({ blockers: source.blockers.map((blocker) => ({
code: blocker.code, 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 } : {}),
})), })),
}; };
} }

View File

@ -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); return Result.ok({ id: UniqueID.generateNewID() } as never);
}, },
} as never, } as never,
readinessChecker: { check: async () => Result.ok({ canIssue: true, blockers: [] }) } as never,
finder: { finder: {
findProformaById: async () => findProformaById: async () =>
Result.ok({ 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); return Result.ok({ id: UniqueID.generateNewID() } as never);
}, },
} as never, } as never,
readinessChecker: { check: async () => Result.ok({ canIssue: true, blockers: [] }) } as never,
finder: { finder: {
findProformaById: async () => Result.ok(proforma), findProformaById: async () => Result.ok(proforma),
} as never, } as never,
@ -353,6 +355,7 @@ test("issue crea VerifactuRecord pending cuando la empresa usa VeriFactu", async
getIssuedInvoiceById: async (id) => getIssuedInvoiceById: async (id) =>
Result.ok(createReloadedIssuedInvoice(id, proforma.companyId)), Result.ok(createReloadedIssuedInvoice(id, proforma.companyId)),
} as never, } as never,
readinessChecker: { check: async () => Result.ok({ canIssue: true, blockers: [] }) } as never,
finder: { finder: {
findProformaById: async () => Result.ok(proforma), findProformaById: async () => Result.ok(proforma),
} as never, } as never,
@ -421,6 +424,7 @@ test("issue no crea VerifactuRecord cuando la empresa no usa VeriFactu", async (
getIssuedInvoiceById: async (id) => getIssuedInvoiceById: async (id) =>
Result.ok(createReloadedIssuedInvoice(id, proforma.companyId)), Result.ok(createReloadedIssuedInvoice(id, proforma.companyId)),
} as never, } as never,
readinessChecker: { check: async () => Result.ok({ canIssue: true, blockers: [] }) } as never,
finder: { finder: {
findProformaById: async () => Result.ok(proforma), findProformaById: async () => Result.ok(proforma),
} as never, } as never,
@ -480,6 +484,7 @@ test("issue propaga el fallo de persistencia VeriFactu y no marca la proforma co
issuedInvoiceServices: { issuedInvoiceServices: {
createIssuedInvoice: async () => Result.fail(new Error("verifactu insert failed")), createIssuedInvoice: async () => Result.fail(new Error("verifactu insert failed")),
} as never, } as never,
readinessChecker: { check: async () => Result.ok({ canIssue: true, blockers: [] }) } as never,
finder: { finder: {
findProformaById: async () => Result.ok(proforma), findProformaById: async () => Result.ok(proforma),
} as never, } as never,
@ -553,6 +558,7 @@ test("issue registra issued_invoice.created con el estado final de la factura",
return Result.ok(reloadedInvoice); return Result.ok(reloadedInvoice);
}, },
}, },
readinessChecker: { check: async () => Result.ok({ canIssue: true, blockers: [] }) } as never,
finder: { findProformaById: async () => Result.ok(proforma) }, finder: { findProformaById: async () => Result.ok(proforma) },
issuer: { issuer: {
issueProforma: (): Result<ProformaToIssuedInvoiceCreateProps, Error> => issueProforma: (): Result<ProformaToIssuedInvoiceCreateProps, Error> =>

View File

@ -7,9 +7,11 @@ import type { IIssuedInvoicePublicServices } from "../../issued-invoices";
import { toIssuedInvoiceAuditState } from "../../issued-invoices/audit/issued-invoice-audit"; import { toIssuedInvoiceAuditState } from "../../issued-invoices/audit/issued-invoice-audit";
import { VerifactuRecord, VerifactuRecordEstado } from "../../../domain"; import { VerifactuRecord, VerifactuRecordEstado } from "../../../domain";
import type { IssueProformaCommand } from "../commands"; import type { IssueProformaCommand } from "../commands";
import { ProformaNotReadyForIssuanceError } from "../errors";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
import type { import type {
IProformaFinder, IProformaFinder,
IProformaIssuanceReadinessChecker,
IProformaIssueReadModelAssembler, IProformaIssueReadModelAssembler,
IProformaIssuer, IProformaIssuer,
} from "../services"; } from "../services";
@ -30,6 +32,7 @@ export class IssueProformaUseCase {
issuedInvoiceServices: IIssuedInvoicePublicServices; issuedInvoiceServices: IIssuedInvoicePublicServices;
audit: IAudit; audit: IAudit;
finder: IProformaFinder; finder: IProformaFinder;
readinessChecker: IProformaIssuanceReadinessChecker;
issuer: IProformaIssuer; issuer: IProformaIssuer;
issueReadModelAssembler: IProformaIssueReadModelAssembler; issueReadModelAssembler: IProformaIssueReadModelAssembler;
issueResponseSnapshotBuilder: IIssueProformaResponseSnapshotBuilder; issueResponseSnapshotBuilder: IIssueProformaResponseSnapshotBuilder;
@ -70,6 +73,18 @@ export class IssueProformaUseCase {
const proforma = proformaResult.data; 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 // 2. Generamos la factura definitiva
const issuedInvoiceId = UniqueID.generateNewID(); const issuedInvoiceId = UniqueID.generateNewID();

View File

@ -91,7 +91,7 @@ export class ReportProformaPdfUseCase {
}); });
const documentResult = await this.deps.documentService.generate(reportSnapshot, { const documentResult = await this.deps.documentService.generate(reportSnapshot, {
companySlug: reportSnapshot.company.slug, companySlug: "", //reportSnapshot.company.slug,
}); });
if (documentResult.isFailure) { if (documentResult.isFailure) {

View File

@ -33,14 +33,17 @@ export interface IIssuedInvoiceItemCreateProps {
taxableAmount: ItemAmount; taxableAmount: ItemAmount;
ivaCode: Maybe<string>; ivaCode: Maybe<string>;
ivaName: Maybe<string>;
ivaPercentage: Maybe<DiscountPercentage>; ivaPercentage: Maybe<DiscountPercentage>;
ivaAmount: ItemAmount; ivaAmount: ItemAmount;
recCode: Maybe<string>; recCode: Maybe<string>;
recName: Maybe<string>;
recPercentage: Maybe<DiscountPercentage>; recPercentage: Maybe<DiscountPercentage>;
recAmount: ItemAmount; recAmount: ItemAmount;
retentionCode: Maybe<string>; retentionCode: Maybe<string>;
retentionName: Maybe<string>;
retentionPercentage: Maybe<DiscountPercentage>; retentionPercentage: Maybe<DiscountPercentage>;
retentionAmount: ItemAmount; retentionAmount: ItemAmount;
@ -72,14 +75,17 @@ export interface IIssuedInvoiceItem {
taxableAmount: ItemAmount; taxableAmount: ItemAmount;
ivaCode: Maybe<string>; ivaCode: Maybe<string>;
ivaName: Maybe<string>;
ivaPercentage: Maybe<DiscountPercentage>; ivaPercentage: Maybe<DiscountPercentage>;
ivaAmount: ItemAmount; ivaAmount: ItemAmount;
recCode: Maybe<string>; recCode: Maybe<string>;
recName: Maybe<string>;
recPercentage: Maybe<DiscountPercentage>; recPercentage: Maybe<DiscountPercentage>;
recAmount: ItemAmount; recAmount: ItemAmount;
retentionCode: Maybe<string>; retentionCode: Maybe<string>;
retentionName: Maybe<string>;
retentionPercentage: Maybe<DiscountPercentage>; retentionPercentage: Maybe<DiscountPercentage>;
retentionAmount: ItemAmount; retentionAmount: ItemAmount;
@ -100,6 +106,9 @@ export class IssuedInvoiceItem
props: IIssuedInvoiceItemCreateProps, props: IIssuedInvoiceItemCreateProps,
id?: UniqueID id?: UniqueID
): Result<IssuedInvoiceItem, Error> { ): Result<IssuedInvoiceItem, Error> {
const taxNamesResult = validateTaxNames(props);
if (taxNamesResult.isFailure) return Result.fail(taxNamesResult.error);
const item = new IssuedInvoiceItem(props, id); const item = new IssuedInvoiceItem(props, id);
// Reglas de negocio / validaciones // Reglas de negocio / validaciones
@ -169,6 +178,9 @@ export class IssuedInvoiceItem
public get ivaCode(): Maybe<string> { public get ivaCode(): Maybe<string> {
return this.props.ivaCode; return this.props.ivaCode;
} }
public get ivaName(): Maybe<string> {
return this.props.ivaName;
}
public get ivaPercentage(): Maybe<TaxPercentage> { public get ivaPercentage(): Maybe<TaxPercentage> {
return this.props.ivaPercentage; return this.props.ivaPercentage;
} }
@ -179,6 +191,9 @@ export class IssuedInvoiceItem
public get recCode(): Maybe<string> { public get recCode(): Maybe<string> {
return this.props.recCode; return this.props.recCode;
} }
public get recName(): Maybe<string> {
return this.props.recName;
}
public get recPercentage(): Maybe<TaxPercentage> { public get recPercentage(): Maybe<TaxPercentage> {
return this.props.recPercentage; return this.props.recPercentage;
} }
@ -189,6 +204,9 @@ export class IssuedInvoiceItem
public get retentionCode(): Maybe<string> { public get retentionCode(): Maybe<string> {
return this.props.retentionCode; return this.props.retentionCode;
} }
public get retentionName(): Maybe<string> {
return this.props.retentionName;
}
public get retentionPercentage(): Maybe<TaxPercentage> { public get retentionPercentage(): Maybe<TaxPercentage> {
return this.props.retentionPercentage; return this.props.retentionPercentage;
} }
@ -216,3 +234,16 @@ export class IssuedInvoiceItem
return this.props.quantity.isSome() || this.props.unitAmount.isSome(); return this.props.quantity.isSome() || this.props.unitAmount.isSome();
} }
} }
function validateTaxNames(props: IIssuedInvoiceItemCreateProps): Result<void, Error> {
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);
}

View File

@ -8,14 +8,17 @@ export type IssuedInvoiceTaxProps = {
taxableAmount: InvoiceAmount; taxableAmount: InvoiceAmount;
ivaCode: string; ivaCode: string;
ivaName: string;
ivaPercentage: Percentage; ivaPercentage: Percentage;
ivaAmount: InvoiceAmount; ivaAmount: InvoiceAmount;
recCode: Maybe<string>; recCode: Maybe<string>;
recName: Maybe<string>;
recPercentage: Maybe<Percentage>; recPercentage: Maybe<Percentage>;
recAmount: InvoiceAmount; recAmount: InvoiceAmount;
retentionCode: Maybe<string>; retentionCode: Maybe<string>;
retentionName: Maybe<string>;
retentionPercentage: Maybe<Percentage>; retentionPercentage: Maybe<Percentage>;
retentionAmount: InvoiceAmount; retentionAmount: InvoiceAmount;
@ -27,6 +30,13 @@ export class IssuedInvoiceTax extends DomainEntity<IssuedInvoiceTaxProps> {
props: IssuedInvoiceTaxProps, props: IssuedInvoiceTaxProps,
id?: UniqueID id?: UniqueID
): Result<IssuedInvoiceTax, Error> { ): Result<IssuedInvoiceTax, Error> {
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)); return Result.ok(new IssuedInvoiceTax(props, id));
} }
@ -37,6 +47,9 @@ export class IssuedInvoiceTax extends DomainEntity<IssuedInvoiceTaxProps> {
public get ivaCode(): string { public get ivaCode(): string {
return this.props.ivaCode; return this.props.ivaCode;
} }
public get ivaName(): string {
return this.props.ivaName;
}
public get ivaPercentage(): TaxPercentage { public get ivaPercentage(): TaxPercentage {
return this.props.ivaPercentage; return this.props.ivaPercentage;
} }
@ -47,6 +60,9 @@ export class IssuedInvoiceTax extends DomainEntity<IssuedInvoiceTaxProps> {
public get recCode(): Maybe<string> { public get recCode(): Maybe<string> {
return this.props.recCode; return this.props.recCode;
} }
public get recName(): Maybe<string> {
return this.props.recName;
}
public get recPercentage(): Maybe<TaxPercentage> { public get recPercentage(): Maybe<TaxPercentage> {
return this.props.recPercentage; return this.props.recPercentage;
} }
@ -57,6 +73,9 @@ export class IssuedInvoiceTax extends DomainEntity<IssuedInvoiceTaxProps> {
public get retentionCode(): Maybe<string> { public get retentionCode(): Maybe<string> {
return this.props.retentionCode; return this.props.retentionCode;
} }
public get retentionName(): Maybe<string> {
return this.props.retentionName;
}
public get retentionPercentage(): Maybe<TaxPercentage> { public get retentionPercentage(): Maybe<TaxPercentage> {
return this.props.retentionPercentage; return this.props.retentionPercentage;
} }

View File

@ -573,6 +573,21 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
* crear una factura emitida y debe ejecutarse mediante el caso de uso de emisión. * crear una factura emitida y debe ejecutarse mediante el caso de uso de emisión.
*/ */
public changeStatus(nextStatus: InvoiceStatus): Result<boolean, Error> { public changeStatus(nextStatus: InvoiceStatus): Result<boolean, Error> {
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<boolean, Error> {
const currentStatus = this.status; const currentStatus = this.status;
if (currentStatus.toPrimitive() === nextStatus.toPrimitive()) { if (currentStatus.toPrimitive() === nextStatus.toPrimitive()) {
@ -589,8 +604,6 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
); );
} }
this.props.status = nextStatus;
return Result.ok(true); return Result.ok(true);
} }
@ -822,6 +835,34 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
return this.items.taxes(this.calculationContext()); return this.items.taxes(this.calculationContext());
} }
public withResolvedItemTaxes(
itemsTaxes: Collection<ProformaItemTaxesProps>
): Result<Proforma, Error> {
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<void, Error> { public addItem(props: IProformaItemCreateProps): Result<void, Error> {
const taxesResult = ProformaItemTaxes.create(props.taxes); const taxesResult = ProformaItemTaxes.create(props.taxes);
if (taxesResult.isFailure) return Result.fail(taxesResult.error); if (taxesResult.isFailure) return Result.fail(taxesResult.error);

View File

@ -10,14 +10,17 @@ type TaxGroupState = {
taxableAmount: ItemAmount; taxableAmount: ItemAmount;
ivaCode: Maybe<string>; ivaCode: Maybe<string>;
ivaName: Maybe<string>;
ivaPercentage: Maybe<TaxPercentage>; ivaPercentage: Maybe<TaxPercentage>;
ivaAmount: ItemAmount; ivaAmount: ItemAmount;
recCode: Maybe<string>; recCode: Maybe<string>;
recName: Maybe<string>;
recPercentage: Maybe<TaxPercentage>; recPercentage: Maybe<TaxPercentage>;
recAmount: ItemAmount; recAmount: ItemAmount;
retentionCode: Maybe<string>; retentionCode: Maybe<string>;
retentionName: Maybe<string>;
retentionPercentage: Maybe<TaxPercentage>; retentionPercentage: Maybe<TaxPercentage>;
retentionAmount: ItemAmount; retentionAmount: ItemAmount;
@ -51,14 +54,17 @@ export function proformaComputeTaxGroups(
taxableAmount: ItemAmount.zero(currency.code), taxableAmount: ItemAmount.zero(currency.code),
ivaCode: iva.isSome() ? Maybe.some(iva.unwrap().code) : Maybe.none(), 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(), ivaPercentage: iva.isSome() ? Maybe.some(iva.unwrap().percentage) : Maybe.none(),
ivaAmount: ItemAmount.zero(currency.code), ivaAmount: ItemAmount.zero(currency.code),
recCode: rec.isSome() ? Maybe.some(rec.unwrap().code) : Maybe.none(), 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(), recPercentage: rec.isSome() ? Maybe.some(rec.unwrap().percentage) : Maybe.none(),
recAmount: ItemAmount.zero(currency.code), recAmount: ItemAmount.zero(currency.code),
retentionCode: retention.isSome() ? Maybe.some(retention.unwrap().code) : Maybe.none(), retentionCode: retention.isSome() ? Maybe.some(retention.unwrap().code) : Maybe.none(),
retentionName: retention.isSome() ? Maybe.some(retention.unwrap().name) : Maybe.none(),
retentionPercentage: retention.isSome() retentionPercentage: retention.isSome()
? Maybe.some(retention.unwrap().percentage) ? Maybe.some(retention.unwrap().percentage)
: Maybe.none(), : Maybe.none(),
@ -70,6 +76,13 @@ export function proformaComputeTaxGroups(
const g = map.get(key)!; 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); const itemTotals = item.totals(context);
g.taxableAmount = g.taxableAmount.add(itemTotals.taxableAmount); g.taxableAmount = g.taxableAmount.add(itemTotals.taxableAmount);
@ -81,6 +94,35 @@ export function proformaComputeTaxGroups(
return map; return map;
} }
function ensureTaxNameMatches(
code: Maybe<string>,
name: Maybe<string>,
tax: Maybe<Tax>,
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<string>,
percentage: Maybe<TaxPercentage>,
tax: Maybe<Tax>,
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<Tax>, rec: Maybe<Tax>, retention: Maybe<Tax>): string { function buildTaxGroupKey(iva: Maybe<Tax>, rec: Maybe<Tax>, retention: Maybe<Tax>): string {
const ivaPart = iva.isSome() ? `${iva.unwrap().code}` : "NULL"; const ivaPart = iva.isSome() ? `${iva.unwrap().code}` : "NULL";
const recPart = rec.isSome() ? `${rec.unwrap().code}` : "NULL"; const recPart = rec.isSome() ? `${rec.unwrap().code}` : "NULL";

View File

@ -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 = <T>(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/);
});

View File

@ -12,14 +12,17 @@ export interface IProformaTaxTotals {
taxableAmount: InvoiceAmount; taxableAmount: InvoiceAmount;
ivaCode: Maybe<string>; ivaCode: Maybe<string>;
ivaName: Maybe<string>;
ivaPercentage: Maybe<TaxPercentage>; ivaPercentage: Maybe<TaxPercentage>;
ivaAmount: InvoiceAmount; ivaAmount: InvoiceAmount;
recCode: Maybe<string>; recCode: Maybe<string>;
recName: Maybe<string>;
recPercentage: Maybe<TaxPercentage>; recPercentage: Maybe<TaxPercentage>;
recAmount: InvoiceAmount; recAmount: InvoiceAmount;
retentionCode: Maybe<string>; retentionCode: Maybe<string>;
retentionName: Maybe<string>;
retentionPercentage: Maybe<TaxPercentage>; retentionPercentage: Maybe<TaxPercentage>;
retentionAmount: InvoiceAmount; retentionAmount: InvoiceAmount;
@ -47,14 +50,17 @@ export class ProformaTaxesCalculator {
taxableAmount, taxableAmount,
ivaCode: g.ivaCode, ivaCode: g.ivaCode,
ivaName: g.ivaName,
ivaPercentage: g.ivaPercentage, ivaPercentage: g.ivaPercentage,
ivaAmount, ivaAmount,
recCode: g.recCode, recCode: g.recCode,
recName: g.recName,
recPercentage: g.recPercentage, recPercentage: g.recPercentage,
recAmount, recAmount,
retentionCode: g.retentionCode, retentionCode: g.retentionCode,
retentionName: g.retentionName,
retentionPercentage: g.retentionPercentage, retentionPercentage: g.retentionPercentage,
retentionAmount, retentionAmount,

View File

@ -62,6 +62,7 @@ export class IssuedInvoiceItemModel extends Model<
// IVA percentage // IVA percentage
declare iva_code: CreationOptional<string | null>; declare iva_code: CreationOptional<string | null>;
declare iva_name: CreationOptional<string | null>;
declare iva_percentage_value: CreationOptional<number | null>; declare iva_percentage_value: CreationOptional<number | null>;
declare iva_percentage_scale: number; declare iva_percentage_scale: number;
@ -72,6 +73,7 @@ export class IssuedInvoiceItemModel extends Model<
// Recargo de equivalencia percentage // Recargo de equivalencia percentage
declare rec_code: CreationOptional<string | null>; declare rec_code: CreationOptional<string | null>;
declare rec_name: CreationOptional<string | null>;
declare rec_percentage_value: CreationOptional<number | null>; declare rec_percentage_value: CreationOptional<number | null>;
declare rec_percentage_scale: number; declare rec_percentage_scale: number;
@ -82,6 +84,7 @@ export class IssuedInvoiceItemModel extends Model<
// Retention percentage // Retention percentage
declare retention_code: CreationOptional<string | null>; declare retention_code: CreationOptional<string | null>;
declare retention_name: CreationOptional<string | null>;
declare retention_percentage_value: CreationOptional<number | null>; declare retention_percentage_value: CreationOptional<number | null>;
declare retention_percentage_scale: number; declare retention_percentage_scale: number;
@ -264,6 +267,11 @@ export default (database: Sequelize) => {
allowNull: true, allowNull: true,
defaultValue: null, defaultValue: null,
}, },
iva_name: {
type: DataTypes.STRING(100),
allowNull: true,
defaultValue: null,
},
iva_percentage_value: { iva_percentage_value: {
type: DataTypes.SMALLINT, type: DataTypes.SMALLINT,
allowNull: true, allowNull: true,
@ -292,6 +300,11 @@ export default (database: Sequelize) => {
allowNull: true, allowNull: true,
defaultValue: null, defaultValue: null,
}, },
rec_name: {
type: DataTypes.STRING(100),
allowNull: true,
defaultValue: null,
},
rec_percentage_value: { rec_percentage_value: {
type: DataTypes.SMALLINT, type: DataTypes.SMALLINT,
@ -321,6 +334,11 @@ export default (database: Sequelize) => {
allowNull: true, allowNull: true,
defaultValue: null, defaultValue: null,
}, },
retention_name: {
type: DataTypes.STRING(100),
allowNull: true,
defaultValue: null,
},
retention_percentage_value: { retention_percentage_value: {
type: DataTypes.SMALLINT, type: DataTypes.SMALLINT,

View File

@ -24,16 +24,19 @@ export class IssuedInvoiceTaxModel extends Model<
declare taxable_amount_value: number; declare taxable_amount_value: number;
declare taxable_amount_scale: number; declare taxable_amount_scale: number;
declare iva_code: CreationOptional<string | null>; declare iva_code: CreationOptional<string | null>;
declare iva_name: CreationOptional<string | null>;
declare iva_percentage_value: CreationOptional<number | null>; declare iva_percentage_value: CreationOptional<number | null>;
declare iva_percentage_scale: number; declare iva_percentage_scale: number;
declare iva_amount_value: CreationOptional<number | null>; declare iva_amount_value: CreationOptional<number | null>;
declare iva_amount_scale: number; declare iva_amount_scale: number;
declare rec_code: CreationOptional<string | null>; declare rec_code: CreationOptional<string | null>;
declare rec_name: CreationOptional<string | null>;
declare rec_percentage_value: CreationOptional<number | null>; declare rec_percentage_value: CreationOptional<number | null>;
declare rec_percentage_scale: number; declare rec_percentage_scale: number;
declare rec_amount_value: CreationOptional<number | null>; declare rec_amount_value: CreationOptional<number | null>;
declare rec_amount_scale: number; declare rec_amount_scale: number;
declare retention_code: CreationOptional<string | null>; declare retention_code: CreationOptional<string | null>;
declare retention_name: CreationOptional<string | null>;
declare retention_percentage_value: CreationOptional<number | null>; declare retention_percentage_value: CreationOptional<number | null>;
declare retention_percentage_scale: number; declare retention_percentage_scale: number;
declare retention_amount_value: CreationOptional<number | null>; declare retention_amount_value: CreationOptional<number | null>;
@ -98,6 +101,11 @@ export default (database: Sequelize) => {
allowNull: true, allowNull: true,
defaultValue: null, defaultValue: null,
}, },
iva_name: {
type: DataTypes.STRING(100),
allowNull: true,
defaultValue: null,
},
iva_percentage_value: { iva_percentage_value: {
type: DataTypes.SMALLINT, type: DataTypes.SMALLINT,
allowNull: true, allowNull: true,
@ -123,6 +131,11 @@ export default (database: Sequelize) => {
allowNull: true, allowNull: true,
defaultValue: null, defaultValue: null,
}, },
rec_name: {
type: DataTypes.STRING(100),
allowNull: true,
defaultValue: null,
},
rec_percentage_value: { rec_percentage_value: {
type: DataTypes.SMALLINT, type: DataTypes.SMALLINT,
allowNull: true, allowNull: true,
@ -148,6 +161,11 @@ export default (database: Sequelize) => {
allowNull: true, allowNull: true,
defaultValue: null, defaultValue: null,
}, },
retention_name: {
type: DataTypes.STRING(100),
allowNull: true,
defaultValue: null,
},
retention_percentage_value: { retention_percentage_value: {
type: DataTypes.SMALLINT, type: DataTypes.SMALLINT,
allowNull: true, allowNull: true,
@ -191,11 +209,6 @@ export default (database: Sequelize) => {
name: "issued_invoice_id_idx", name: "issued_invoice_id_idx",
fields: ["issued_invoice_id"], fields: ["issued_invoice_id"],
}, },
{
name: "issued_invoice_iva_code_unique",
fields: ["issued_invoice_id", "iva_code"],
unique: true,
},
], ],
whereMergeStrategy: "and", whereMergeStrategy: "and",
defaultScope: {}, defaultScope: {},

View File

@ -42,16 +42,25 @@ export class ProformaItemModel extends Model<
declare taxable_amount_value: number; declare taxable_amount_value: number;
declare taxable_amount_scale: number; declare taxable_amount_scale: number;
declare iva_code: CreationOptional<string | null>; declare iva_code: CreationOptional<string | null>;
declare iva_name: CreationOptional<string | null>;
declare iva_group: CreationOptional<string | null>;
declare iva_calculation_behavior: CreationOptional<string | null>;
declare iva_percentage_value: CreationOptional<number | null>; declare iva_percentage_value: CreationOptional<number | null>;
declare iva_percentage_scale: number; declare iva_percentage_scale: number;
declare iva_amount_value: number; declare iva_amount_value: number;
declare iva_amount_scale: number; declare iva_amount_scale: number;
declare rec_code: CreationOptional<string | null>; declare rec_code: CreationOptional<string | null>;
declare rec_name: CreationOptional<string | null>;
declare rec_group: CreationOptional<string | null>;
declare rec_calculation_behavior: CreationOptional<string | null>;
declare rec_percentage_value: CreationOptional<number | null>; declare rec_percentage_value: CreationOptional<number | null>;
declare rec_percentage_scale: number; declare rec_percentage_scale: number;
declare rec_amount_value: number; declare rec_amount_value: number;
declare rec_amount_scale: number; declare rec_amount_scale: number;
declare retention_code: CreationOptional<string | null>; declare retention_code: CreationOptional<string | null>;
declare retention_name: CreationOptional<string | null>;
declare retention_group: CreationOptional<string | null>;
declare retention_calculation_behavior: CreationOptional<string | null>;
declare retention_percentage_value: CreationOptional<number | null>; declare retention_percentage_value: CreationOptional<number | null>;
declare retention_percentage_scale: number; declare retention_percentage_scale: number;
declare retention_amount_value: number; declare retention_amount_value: number;
@ -207,6 +216,9 @@ export default (database: Sequelize) => {
allowNull: true, allowNull: true,
defaultValue: null, 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: { iva_percentage_value: {
type: DataTypes.SMALLINT, type: DataTypes.SMALLINT,
allowNull: true, allowNull: true,
@ -232,6 +244,9 @@ export default (database: Sequelize) => {
allowNull: true, allowNull: true,
defaultValue: null, 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: { rec_percentage_value: {
type: DataTypes.SMALLINT, type: DataTypes.SMALLINT,
allowNull: true, allowNull: true,
@ -257,6 +272,9 @@ export default (database: Sequelize) => {
allowNull: true, allowNull: true,
defaultValue: null, 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: { retention_percentage_value: {
type: DataTypes.SMALLINT, type: DataTypes.SMALLINT,
allowNull: true, allowNull: true,

View File

@ -191,11 +191,6 @@ export default (database: Sequelize) => {
name: "proforma_id_idx", name: "proforma_id_idx",
fields: ["proforma_id"], fields: ["proforma_id"],
}, },
{
name: "proforma_iva_code_unique",
fields: ["proforma_id", "iva_code"],
unique: true,
},
], ],
whereMergeStrategy: "and", whereMergeStrategy: "and",
defaultScope: {}, defaultScope: {},

View File

@ -132,6 +132,8 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
errors errors
); );
const ivaCode = maybeFromNullableOrEmptyString(raw.iva_code); const ivaCode = maybeFromNullableOrEmptyString(raw.iva_code);
const ivaName = maybeFromNullableOrEmptyString(raw.iva_name);
ensureCodeAndName(ivaCode, ivaName, `items[${index}].iva`, errors);
const ivaPercentage = extractOrPushError( const ivaPercentage = extractOrPushError(
maybeFromNullableResult(raw.iva_percentage_value, (value) => TaxPercentage.create({ value })), maybeFromNullableResult(raw.iva_percentage_value, (value) => TaxPercentage.create({ value })),
`items[${index}].iva_percentage_value`, `items[${index}].iva_percentage_value`,
@ -146,6 +148,8 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
errors errors
); );
const recCode = maybeFromNullableOrEmptyString(raw.rec_code); const recCode = maybeFromNullableOrEmptyString(raw.rec_code);
const recName = maybeFromNullableOrEmptyString(raw.rec_name);
ensureCodeAndName(recCode, recName, `items[${index}].rec`, errors);
const recPercentage = extractOrPushError( const recPercentage = extractOrPushError(
maybeFromNullableResult(raw.rec_percentage_value, (value) => TaxPercentage.create({ value })), maybeFromNullableResult(raw.rec_percentage_value, (value) => TaxPercentage.create({ value })),
`items[${index}].rec_percentage_value`, `items[${index}].rec_percentage_value`,
@ -160,6 +164,8 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
errors errors
); );
const retentionCode = maybeFromNullableOrEmptyString(raw.retention_code); const retentionCode = maybeFromNullableOrEmptyString(raw.retention_code);
const retentionName = maybeFromNullableOrEmptyString(raw.retention_name);
ensureCodeAndName(retentionCode, retentionName, `items[${index}].retention`, errors);
const retentionPercentage = extractOrPushError( const retentionPercentage = extractOrPushError(
maybeFromNullableResult(raw.retention_percentage_value, (value) => maybeFromNullableResult(raw.retention_percentage_value, (value) =>
TaxPercentage.create({ value }) TaxPercentage.create({ value })
@ -207,12 +213,15 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
totalDiscountAmount, totalDiscountAmount,
taxableAmount, taxableAmount,
ivaCode, ivaCode,
ivaName,
ivaPercentage, ivaPercentage,
ivaAmount, ivaAmount,
recCode, recCode,
recName,
recPercentage, recPercentage,
recAmount, recAmount,
retentionCode, retentionCode,
retentionName,
retentionPercentage, retentionPercentage,
retentionAmount, retentionAmount,
taxesAmount, taxesAmount,
@ -250,12 +259,15 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
totalDiscountAmount: attributes.totalDiscountAmount!, totalDiscountAmount: attributes.totalDiscountAmount!,
taxableAmount: attributes.taxableAmount!, taxableAmount: attributes.taxableAmount!,
ivaCode: attributes.ivaCode!, ivaCode: attributes.ivaCode!,
ivaName: attributes.ivaName!,
ivaPercentage: attributes.ivaPercentage!, ivaPercentage: attributes.ivaPercentage!,
ivaAmount: attributes.ivaAmount!, ivaAmount: attributes.ivaAmount!,
recCode: attributes.recCode!, recCode: attributes.recCode!,
recName: attributes.recName!,
recPercentage: attributes.recPercentage!, recPercentage: attributes.recPercentage!,
recAmount: attributes.recAmount!, recAmount: attributes.recAmount!,
retentionCode: attributes.retentionCode!, retentionCode: attributes.retentionCode!,
retentionName: attributes.retentionName!,
retentionPercentage: attributes.retentionPercentage!, retentionPercentage: attributes.retentionPercentage!,
retentionAmount: attributes.retentionAmount!, retentionAmount: attributes.retentionAmount!,
taxesAmount: attributes.taxesAmount!, taxesAmount: attributes.taxesAmount!,
@ -324,6 +336,7 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
// IVA // IVA
iva_code: maybeToNullableString(source.ivaCode), iva_code: maybeToNullableString(source.ivaCode),
iva_name: maybeToNullableString(source.ivaName),
iva_percentage_value: maybeToNullable(source.ivaPercentage, (v) => v.toPrimitive().value), iva_percentage_value: maybeToNullable(source.ivaPercentage, (v) => v.toPrimitive().value),
iva_percentage_scale: iva_percentage_scale:
@ -334,6 +347,7 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
// REC // REC
rec_code: maybeToNullableString(source.recCode), rec_code: maybeToNullableString(source.recCode),
rec_name: maybeToNullableString(source.recName),
rec_percentage_value: maybeToNullable(source.recPercentage, (v) => v.toPrimitive().value), rec_percentage_value: maybeToNullable(source.recPercentage, (v) => v.toPrimitive().value),
rec_percentage_scale: rec_percentage_scale:
@ -344,6 +358,7 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
// RET // RET
retention_code: maybeToNullableString(source.retentionCode), retention_code: maybeToNullableString(source.retentionCode),
retention_name: maybeToNullableString(source.retentionName),
retention_percentage_value: maybeToNullable( retention_percentage_value: maybeToNullable(
source.retentionPercentage, source.retentionPercentage,
@ -365,3 +380,14 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
} satisfies IssuedInvoiceItemCreationAttributes); } satisfies IssuedInvoiceItemCreationAttributes);
} }
} }
function ensureCodeAndName(
code: ReturnType<typeof maybeFromNullableOrEmptyString>,
name: ReturnType<typeof maybeFromNullableOrEmptyString>,
path: string,
errors: ValidationErrorDetail[]
): void {
if (code.isSome() !== name.isSome()) {
errors.push({ path, message: "tax code and name must be present together" });
}
}

View File

@ -59,6 +59,10 @@ export class SequelizeIssuedInvoiceV2TaxesDomainMapper extends SequelizeDomainMa
errors errors
); );
const ivaCode = raw.iva_code; 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( const ivaPercentage = extractOrPushError(
TaxPercentage.create({ value: Number(raw.iva_percentage_value) }), TaxPercentage.create({ value: Number(raw.iva_percentage_value) }),
`taxes[${index}].iva_percentage_value`, `taxes[${index}].iva_percentage_value`,
@ -70,6 +74,8 @@ export class SequelizeIssuedInvoiceV2TaxesDomainMapper extends SequelizeDomainMa
errors errors
); );
const recCode = maybeFromNullableOrEmptyString(raw.rec_code); const recCode = maybeFromNullableOrEmptyString(raw.rec_code);
const recName = maybeFromNullableOrEmptyString(raw.rec_name);
ensureCodeAndName(recCode, recName, `taxes[${index}].rec`, errors);
const recPercentage = extractOrPushError( const recPercentage = extractOrPushError(
maybeFromNullableResult(raw.rec_percentage_value, (value) => TaxPercentage.create({ value })), maybeFromNullableResult(raw.rec_percentage_value, (value) => TaxPercentage.create({ value })),
`taxes[${index}].rec_percentage_value`, `taxes[${index}].rec_percentage_value`,
@ -81,6 +87,8 @@ export class SequelizeIssuedInvoiceV2TaxesDomainMapper extends SequelizeDomainMa
errors errors
); );
const retentionCode = maybeFromNullableOrEmptyString(raw.retention_code); const retentionCode = maybeFromNullableOrEmptyString(raw.retention_code);
const retentionName = maybeFromNullableOrEmptyString(raw.retention_name);
ensureCodeAndName(retentionCode, retentionName, `taxes[${index}].retention`, errors);
const retentionPercentage = extractOrPushError( const retentionPercentage = extractOrPushError(
maybeFromNullableResult(raw.retention_percentage_value, (value) => TaxPercentage.create({ value })), maybeFromNullableResult(raw.retention_percentage_value, (value) => TaxPercentage.create({ value })),
`taxes[${index}].retention_percentage_value`, `taxes[${index}].retention_percentage_value`,
@ -109,12 +117,15 @@ export class SequelizeIssuedInvoiceV2TaxesDomainMapper extends SequelizeDomainMa
return IssuedInvoiceTax.create({ return IssuedInvoiceTax.create({
taxableAmount: taxableAmount!, taxableAmount: taxableAmount!,
ivaCode: ivaCode!, ivaCode: ivaCode!,
ivaName: ivaName!,
ivaPercentage: ivaPercentage!, ivaPercentage: ivaPercentage!,
ivaAmount: ivaAmount!, ivaAmount: ivaAmount!,
recCode, recCode,
recName,
recPercentage: recPercentage!, recPercentage: recPercentage!,
recAmount: recAmount!, recAmount: recAmount!,
retentionCode, retentionCode,
retentionName,
retentionPercentage: retentionPercentage!, retentionPercentage: retentionPercentage!,
retentionAmount: retentionAmount!, retentionAmount: retentionAmount!,
taxesAmount: taxesAmount!, taxesAmount: taxesAmount!,
@ -137,11 +148,13 @@ export class SequelizeIssuedInvoiceV2TaxesDomainMapper extends SequelizeDomainMa
taxable_amount_value: source.taxableAmount.value, taxable_amount_value: source.taxableAmount.value,
taxable_amount_scale: source.taxableAmount.scale, taxable_amount_scale: source.taxableAmount.scale,
iva_code: source.ivaCode, iva_code: source.ivaCode,
iva_name: source.ivaName,
iva_percentage_value: source.ivaPercentage.value, iva_percentage_value: source.ivaPercentage.value,
iva_percentage_scale: source.ivaPercentage.scale, iva_percentage_scale: source.ivaPercentage.scale,
iva_amount_value: source.ivaAmount.value, iva_amount_value: source.ivaAmount.value,
iva_amount_scale: source.ivaAmount.scale, iva_amount_scale: source.ivaAmount.scale,
rec_code: maybeToNullableString(source.recCode), rec_code: maybeToNullableString(source.recCode),
rec_name: maybeToNullableString(source.recName),
rec_percentage_value: maybeToNullable(source.recPercentage, (v) => v.toPrimitive().value), rec_percentage_value: maybeToNullable(source.recPercentage, (v) => v.toPrimitive().value),
rec_percentage_scale: rec_percentage_scale:
maybeToNullable(source.recPercentage, (v) => v.toPrimitive().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_value: source.recAmount.toPrimitive().value,
rec_amount_scale: source.recAmount.toPrimitive().scale ?? ItemAmount.DEFAULT_SCALE, rec_amount_scale: source.recAmount.toPrimitive().scale ?? ItemAmount.DEFAULT_SCALE,
retention_code: maybeToNullableString(source.retentionCode), retention_code: maybeToNullableString(source.retentionCode),
retention_name: maybeToNullableString(source.retentionName),
retention_percentage_value: maybeToNullable( retention_percentage_value: maybeToNullable(
source.retentionPercentage, source.retentionPercentage,
(v) => v.toPrimitive().value (v) => v.toPrimitive().value
@ -167,3 +181,14 @@ export class SequelizeIssuedInvoiceV2TaxesDomainMapper extends SequelizeDomainMa
} }
} }
} }
function ensureCodeAndName(
code: ReturnType<typeof maybeFromNullableOrEmptyString>,
name: ReturnType<typeof maybeFromNullableOrEmptyString>,
path: string,
errors: ValidationErrorDetail[]
): void {
if (code.isSome() !== name.isSome()) {
errors.push({ path, message: "tax code and name must be present together" });
}
}

View File

@ -67,7 +67,6 @@ export function buildProformaPublicServices(
documentSeriesServices, documentSeriesServices,
}); });
const { repository, numberGenerator: numberService } = persistence; const { repository, numberGenerator: numberService } = persistence;
const finder = buildProformaFinder(repository);
const catalogResolvers = buildProformaCatalogResolvers({ const catalogResolvers = buildProformaCatalogResolvers({
taxDefinitionFinder: catalogs.taxDefinition.finder, taxDefinitionFinder: catalogs.taxDefinition.finder,
@ -75,6 +74,7 @@ export function buildProformaPublicServices(
paymentMethodFinder: catalogs.paymentMethod.finder, paymentMethodFinder: catalogs.paymentMethod.finder,
paymentTermFinder: catalogs.paymentTerm.finder, paymentTermFinder: catalogs.paymentTerm.finder,
}); });
const finder = buildProformaFinder({ repository, taxResolver: catalogResolvers.taxResolver });
const readModelAssemblers = buildProformaReadModelAssemblers({ const readModelAssemblers = buildProformaReadModelAssemblers({
paymentMethodFinder: catalogs.paymentMethod.finder, paymentMethodFinder: catalogs.paymentMethod.finder,

View File

@ -16,6 +16,7 @@ import {
type ListProformasUseCase, type ListProformasUseCase,
type PreviewProformaReportUseCase, type PreviewProformaReportUseCase,
ProformaIssuanceReadinessChecker, ProformaIssuanceReadinessChecker,
ProformaTaxAvailabilityChecker,
ProformaTaxConfigResolver, ProformaTaxConfigResolver,
RecurringProformaActivationReadinessChecker, RecurringProformaActivationReadinessChecker,
RecurringProformaCommands, RecurringProformaCommands,
@ -156,7 +157,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
taxRegimeFinder: catalogs.taxRegime.finder, taxRegimeFinder: catalogs.taxRegime.finder,
}); });
const finder = buildProformaFinder(repository); const finder = buildProformaFinder({ repository, taxResolver: catalogResolvers.taxResolver });
const proformaSeriesValidator = new DocumentSeriesProformaSeriesValidator(documentSeriesServices); const proformaSeriesValidator = new DocumentSeriesProformaSeriesValidator(documentSeriesServices);
const targetInvoiceSeriesValidator = new DocumentSeriesProformaTargetInvoiceSeriesValidator( const targetInvoiceSeriesValidator = new DocumentSeriesProformaTargetInvoiceSeriesValidator(
documentSeriesServices documentSeriesServices
@ -282,6 +283,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
const statusChanger = buildProformaStatusChanger({ const statusChanger = buildProformaStatusChanger({
repository, repository,
taxAvailabilityChecker: new ProformaTaxAvailabilityChecker(catalogs.taxDefinition.finder),
audit: params.audit, audit: params.audit,
}); });
@ -300,6 +302,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
}); });
const issuanceReadinessChecker = new ProformaIssuanceReadinessChecker({ const issuanceReadinessChecker = new ProformaIssuanceReadinessChecker({
paymentMethodFinder: catalogs.paymentMethod.finder, paymentMethodFinder: catalogs.paymentMethod.finder,
taxAvailabilityChecker: new ProformaTaxAvailabilityChecker(catalogs.taxDefinition.finder),
}); });
const companyReportProfileFinder = new CompanyReportProfileFinder({ const companyReportProfileFinder = new CompanyReportProfileFinder({
@ -332,6 +335,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
listProformas: () => listProformas: () =>
buildListProformasUseCase({ buildListProformasUseCase({
finder, finder,
readinessChecker: issuanceReadinessChecker,
summarySnapshotBuilder: snapshotBuilders.summary, summarySnapshotBuilder: snapshotBuilders.summary,
transactionManager, transactionManager,
}), }),
@ -430,6 +434,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
buildIssueProformaUseCase({ buildIssueProformaUseCase({
publicServices, publicServices,
finder, finder,
readinessChecker: issuanceReadinessChecker,
issuer, issuer,
issueReadModelAssembler, issueReadModelAssembler,
issueResponseSnapshotBuilder: snapshotBuilders.issue, issueResponseSnapshotBuilder: snapshotBuilders.issue,

View File

@ -5,20 +5,23 @@ import type { ProformaReportSnapshot } from "../../../../application";
export class ProformaPdfDocumentMetadataFactory export class ProformaPdfDocumentMetadataFactory
implements IDocumentMetadataFactory<ProformaReportSnapshot> implements IDocumentMetadataFactory<ProformaReportSnapshot>
{ {
build(snapshot: ProformaReportSnapshot): IDocumentMetadata { build(snapshot: ProformaReportSnapshot, context: Record<string, string>): IDocumentMetadata {
return { return {
documentType: "proforma", documentType: "proforma",
documentId: snapshot.id, documentId: snapshot.id,
companyId: snapshot.company_id, companyId: context.company_id,
companySlug: snapshot.company.slug, companySlug: context.company.slug,
format: "PDF", format: "PDF",
languageCode: snapshot.localization.languageCode, languageCode: context.localization.languageCode,
filename: `proforma-${snapshot.proforma_reference}.pdf`, filename: `proforma-${snapshot.proforma_reference}.pdf`,
storageKey: this.buildStorageKey(snapshot), storageKey: this.buildStorageKey(snapshot),
}; };
} }
private buildStorageKey(snapshot: ProformaReportSnapshot): string | undefined { private buildStorageKey(
snapshot: ProformaReportSnapshot,
context: Record<string, string>
): string | undefined {
if (snapshot.status === "draft") { if (snapshot.status === "draft") {
return undefined; return undefined;
} }
@ -32,12 +35,12 @@ export class ProformaPdfDocumentMetadataFactory
snapshot.id, snapshot.id,
snapshot.status, snapshot.status,
"pdf", "pdf",
snapshot.localization.languageCode, context.localization.languageCode,
snapshot.localization.currencyCode, context.localization.currencyCode,
snapshot.company.slug, context.company.slug,
]; ];
const companyVersion = snapshot.company.version.getOrUndefined(); const companyVersion = context.company.version.getOrUndefined();
if (companyVersion) { if (companyVersion) {
keyParts.push(companyVersion); keyParts.push(companyVersion);

View File

@ -14,8 +14,12 @@ import {
InvoiceSeriesNotFoundError, InvoiceSeriesNotFoundError,
InvoiceSeriesTransactionRequiredError, InvoiceSeriesTransactionRequiredError,
IssuedInvoiceNumberAlreadyExistsError, IssuedInvoiceNumberAlreadyExistsError,
ProformaTaxesUnavailableError,
ProformaNotReadyForIssuanceError,
isCompanyReportProfileNotFoundError, isCompanyReportProfileNotFoundError,
isIssuedInvoiceNumberAlreadyExistsError, isIssuedInvoiceNumberAlreadyExistsError,
isProformaTaxesUnavailableError,
isProformaNotReadyForIssuanceError,
} from "../../../application"; } from "../../../application";
import { import {
type CustomerInvoiceIdAlreadyExistsError, 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 = { const proformaConversionRule: ErrorToApiRule = {
priority: 120, priority: 120,
matches: (e) => isProformaCannotBeConvertedToInvoiceError(e), matches: (e) => isProformaCannotBeConvertedToInvoiceError(e),
@ -227,4 +247,6 @@ export const proformasApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default()
.register(proformaCannotBeUnarchivedRule) .register(proformaCannotBeUnarchivedRule)
.register(proformaCannotBeArchivedRule) .register(proformaCannotBeArchivedRule)
.register(proformaCannotBeDeletedRule) .register(proformaCannotBeDeletedRule)
.register(proformaTaxesUnavailableRule)
.register(proformaNotReadyForIssuanceRule)
.register(proformaTransitionRule); .register(proformaTransitionRule);

View File

@ -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 = <T>(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<typeof ProformaItem.create>[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);
});

View File

@ -89,9 +89,10 @@ export class SequelizeProformaV2ItemDomainMapper extends SequelizeDomainMapper<
const iva = extractOrPushError( const iva = extractOrPushError(
this.mapTaxToDomain({ this.mapTaxToDomain({
code: raw.iva_code, code: raw.iva_code,
name: raw.iva_name,
group: raw.iva_group,
calculationBehavior: raw.iva_calculation_behavior,
percentageValue: raw.iva_percentage_value, percentageValue: raw.iva_percentage_value,
group: "iva",
calculationBehavior: "additive",
fieldPath: `items[${index}].iva`, fieldPath: `items[${index}].iva`,
}), }),
`items[${index}].iva`, `items[${index}].iva`,
@ -101,9 +102,10 @@ export class SequelizeProformaV2ItemDomainMapper extends SequelizeDomainMapper<
const rec = extractOrPushError( const rec = extractOrPushError(
this.mapTaxToDomain({ this.mapTaxToDomain({
code: raw.rec_code, code: raw.rec_code,
name: raw.rec_name,
group: raw.rec_group,
calculationBehavior: raw.rec_calculation_behavior,
percentageValue: raw.rec_percentage_value, percentageValue: raw.rec_percentage_value,
group: "surcharge",
calculationBehavior: "additive",
fieldPath: `items[${index}].rec`, fieldPath: `items[${index}].rec`,
}), }),
`items[${index}].rec`, `items[${index}].rec`,
@ -113,9 +115,10 @@ export class SequelizeProformaV2ItemDomainMapper extends SequelizeDomainMapper<
const retention = extractOrPushError( const retention = extractOrPushError(
this.mapTaxToDomain({ this.mapTaxToDomain({
code: raw.retention_code, code: raw.retention_code,
name: raw.retention_name,
group: raw.retention_group,
calculationBehavior: raw.retention_calculation_behavior,
percentageValue: raw.retention_percentage_value, percentageValue: raw.retention_percentage_value,
group: "retention",
calculationBehavior: "subtractive",
fieldPath: `items[${index}].retention`, fieldPath: `items[${index}].retention`,
}), }),
`items[${index}].retention`, `items[${index}].retention`,
@ -136,12 +139,24 @@ export class SequelizeProformaV2ItemDomainMapper extends SequelizeDomainMapper<
private mapTaxToDomain(params: { private mapTaxToDomain(params: {
code: string | null; code: string | null;
name: string | null;
percentageValue: number | null; percentageValue: number | null;
group: TaxGroup; group: string | null;
calculationBehavior: TaxCalculationBehavior; calculationBehavior: string | null;
fieldPath: string; fieldPath: string;
}): Result<Maybe<Tax>, Error> { }): Result<Maybe<Tax>, Error> {
if (params.code === null || params.code.trim() === "") { 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()); 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`) 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({ const percentageResult = TaxPercentage.create({
value: params.percentageValue, value: params.percentageValue,
@ -161,10 +179,10 @@ export class SequelizeProformaV2ItemDomainMapper extends SequelizeDomainMapper<
const taxResult = Tax.create({ const taxResult = Tax.create({
code: params.code, code: params.code,
name: params.code, name: params.name,
rate: percentageResult.data, rate: percentageResult.data,
group: params.group, group: params.group as TaxGroup,
calculationBehavior: params.calculationBehavior, calculationBehavior: params.calculationBehavior as TaxCalculationBehavior,
}); });
if (taxResult.isFailure) { if (taxResult.isFailure) {
@ -263,18 +281,36 @@ export class SequelizeProformaV2ItemDomainMapper extends SequelizeDomainMapper<
taxable_amount_value: allAmounts.taxableAmount.value, taxable_amount_value: allAmounts.taxableAmount.value,
taxable_amount_scale: allAmounts.taxableAmount.scale, taxable_amount_scale: allAmounts.taxableAmount.scale,
iva_code: maybeToNullable(source.taxes.iva, (value) => value.code), 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_value: maybeToNullable(source.taxes.iva, (value) => value.percentage.value),
iva_percentage_scale: iva_percentage_scale:
maybeToNullable(source.taxes.iva, (value) => value.percentage.scale) ?? Tax.DEFAULT_SCALE, maybeToNullable(source.taxes.iva, (value) => value.percentage.scale) ?? Tax.DEFAULT_SCALE,
iva_amount_value: allAmounts.ivaAmount.value, iva_amount_value: allAmounts.ivaAmount.value,
iva_amount_scale: allAmounts.ivaAmount.scale, iva_amount_scale: allAmounts.ivaAmount.scale,
rec_code: maybeToNullable(source.taxes.rec, (value) => value.code), 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_value: maybeToNullable(source.taxes.rec, (value) => value.percentage.value),
rec_percentage_scale: rec_percentage_scale:
maybeToNullable(source.taxes.rec, (value) => value.percentage.scale) ?? Tax.DEFAULT_SCALE, maybeToNullable(source.taxes.rec, (value) => value.percentage.scale) ?? Tax.DEFAULT_SCALE,
rec_amount_value: allAmounts.recAmount.value, rec_amount_value: allAmounts.recAmount.value,
rec_amount_scale: allAmounts.recAmount.scale, rec_amount_scale: allAmounts.recAmount.scale,
retention_code: maybeToNullable(source.taxes.retention, (value) => value.code), 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( retention_percentage_value: maybeToNullable(
source.taxes.retention, source.taxes.retention,
(value) => value.percentage.value (value) => value.percentage.value

View File

@ -11,10 +11,15 @@ export const ProformaIssuanceBlockerCodeSchema = z.enum([
"missing_tax_regime", "missing_tax_regime",
"payment_method_unavailable", "payment_method_unavailable",
"payment_method_inactive", "payment_method_inactive",
"tax_unavailable",
]); ]);
export const ProformaIssuanceBlockerSchema = z.object({ export const ProformaIssuanceBlockerSchema = z.object({
code: ProformaIssuanceBlockerCodeSchema, 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({ export const GetProformaIssuanceReadinessResponseSchema = z.object({

View File

@ -5,14 +5,17 @@ export const TaxesBreakdownSchema = z.object({
taxable_amount: MoneySchema, taxable_amount: MoneySchema,
iva_code: z.string().nullable(), iva_code: z.string().nullable(),
iva_name: z.string().nullable(),
iva_percentage: PercentageSchema.nullable(), iva_percentage: PercentageSchema.nullable(),
iva_amount: MoneySchema, iva_amount: MoneySchema,
rec_code: z.string().nullable(), rec_code: z.string().nullable(),
rec_name: z.string().nullable(),
rec_percentage: PercentageSchema.nullable(), rec_percentage: PercentageSchema.nullable(),
rec_amount: MoneySchema, rec_amount: MoneySchema,
retention_code: z.string().nullable(), retention_code: z.string().nullable(),
retention_name: z.string().nullable(),
retention_percentage: PercentageSchema.nullable(), retention_percentage: PercentageSchema.nullable(),
retention_amount: MoneySchema, retention_amount: MoneySchema,