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_scale SMALLINT NOT NULL DEFAULT 4,
iva_code VARCHAR(40) NULL,
iva_name VARCHAR(100) NULL,
iva_group VARCHAR(40) NULL,
iva_calculation_behavior VARCHAR(40) NULL,
iva_percentage_value SMALLINT NULL,
iva_percentage_scale SMALLINT NOT NULL DEFAULT 2,
iva_amount_value BIGINT NOT NULL DEFAULT 0,
iva_amount_scale SMALLINT NOT NULL DEFAULT 4,
rec_code VARCHAR(40) NULL,
rec_name VARCHAR(100) NULL,
rec_group VARCHAR(40) NULL,
rec_calculation_behavior VARCHAR(40) NULL,
rec_percentage_value SMALLINT NULL,
rec_percentage_scale SMALLINT NOT NULL DEFAULT 2,
rec_amount_value BIGINT NOT NULL DEFAULT 0,
rec_amount_scale SMALLINT NOT NULL DEFAULT 4,
retention_code VARCHAR(40) NULL,
retention_name VARCHAR(100) NULL,
retention_group VARCHAR(40) NULL,
retention_calculation_behavior VARCHAR(40) NULL,
retention_percentage_value SMALLINT NULL,
retention_percentage_scale SMALLINT NOT NULL DEFAULT 2,
retention_amount_value BIGINT NOT NULL DEFAULT 0,
@ -159,7 +168,6 @@ CREATE TABLE IF NOT EXISTS proforma_taxes (
taxes_amount_value BIGINT NOT NULL DEFAULT 0,
taxes_amount_scale SMALLINT NOT NULL DEFAULT 4,
PRIMARY KEY (tax_id),
UNIQUE KEY proforma_iva_code_unique (proforma_id, iva_code),
KEY proforma_id_idx (proforma_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
@ -260,16 +268,19 @@ CREATE TABLE IF NOT EXISTS issued_invoice_items (
taxable_amount_value BIGINT NOT NULL DEFAULT 0,
taxable_amount_scale SMALLINT NOT NULL DEFAULT 4,
iva_code VARCHAR(40) NULL,
iva_name VARCHAR(100) NULL,
iva_percentage_value SMALLINT NULL,
iva_percentage_scale SMALLINT NOT NULL DEFAULT 2,
iva_amount_value BIGINT NOT NULL DEFAULT 0,
iva_amount_scale SMALLINT NOT NULL DEFAULT 4,
rec_code VARCHAR(40) NULL,
rec_name VARCHAR(100) NULL,
rec_percentage_value SMALLINT NULL,
rec_percentage_scale SMALLINT NOT NULL DEFAULT 2,
rec_amount_value BIGINT NOT NULL DEFAULT 0,
rec_amount_scale SMALLINT NOT NULL DEFAULT 4,
retention_code VARCHAR(40) NULL,
retention_name VARCHAR(100) NULL,
retention_percentage_value SMALLINT NULL,
retention_percentage_scale SMALLINT NOT NULL DEFAULT 2,
retention_amount_value BIGINT NOT NULL DEFAULT 0,
@ -289,16 +300,19 @@ CREATE TABLE IF NOT EXISTS issued_invoice_taxes (
taxable_amount_value BIGINT NOT NULL DEFAULT 0,
taxable_amount_scale SMALLINT NOT NULL DEFAULT 4,
iva_code VARCHAR(40) NULL,
iva_name VARCHAR(100) NULL,
iva_percentage_value SMALLINT NULL,
iva_percentage_scale SMALLINT NOT NULL DEFAULT 2,
iva_amount_value BIGINT NULL,
iva_amount_scale SMALLINT NOT NULL DEFAULT 4,
rec_code VARCHAR(40) NULL,
rec_name VARCHAR(100) NULL,
rec_percentage_value SMALLINT NULL,
rec_percentage_scale SMALLINT NOT NULL DEFAULT 2,
rec_amount_value BIGINT NULL,
rec_amount_scale SMALLINT NOT NULL DEFAULT 4,
retention_code VARCHAR(40) NULL,
retention_name VARCHAR(100) NULL,
retention_percentage_value SMALLINT NULL,
retention_percentage_scale SMALLINT NOT NULL DEFAULT 2,
retention_amount_value BIGINT NULL,
@ -306,7 +320,6 @@ CREATE TABLE IF NOT EXISTS issued_invoice_taxes (
taxes_amount_value BIGINT NOT NULL DEFAULT 0,
taxes_amount_scale SMALLINT NOT NULL DEFAULT 4,
PRIMARY KEY (tax_id),
UNIQUE KEY issued_invoice_iva_code_unique (issued_invoice_id, iva_code),
KEY issued_invoice_id_idx (issued_invoice_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,11 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DiscountPercentage } from "@erp/core/api";
import { DiscountPercentage, TaxPercentage } from "@erp/core/api";
import { CurrencyCode, LanguageCode, UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { InvoiceAmount } from "../../../domain";
import { ProformaToIssuedInvoiceConverter } from "./proforma-to-issued-invoice-props-converter";
test("toCreateProps no materializa VerifactuRecord ni policy en el converter", () => {
@ -155,3 +156,27 @@ test("resolvePaymentTerm convierte el DTO de porcentaje al formato de dominio",
},
]);
});
test("resolveTax copia los nombres preservados por Proforma.taxes sin reconstruirlos", () => {
const converter = new ProformaToIssuedInvoiceConverter();
const percentage = TaxPercentage.create({ value: 2200 }).data;
const amount = InvoiceAmount.create({ value: 2200, currency_code: "EUR" }).data;
const taxableAmount = InvoiceAmount.create({ value: 10000, currency_code: "EUR" }).data;
const result = (converter as unknown as {
resolveTax: (tax: unknown) => Result<{ ivaName: string; ivaCode: string }, Error>;
}).resolveTax({
taxableAmount,
ivaCode: Maybe.some("iva_21"),
ivaName: Maybe.some("IVA actualizado"),
ivaPercentage: Maybe.some(percentage),
ivaAmount: amount,
recCode: Maybe.none(), recName: Maybe.none(), recPercentage: Maybe.none(), recAmount: amount,
retentionCode: Maybe.none(), retentionName: Maybe.none(), retentionPercentage: Maybe.none(), retentionAmount: amount,
taxesAmount: amount,
});
assert.equal(result.isSuccess, true);
assert.equal(result.data.ivaName, "IVA actualizado");
assert.notEqual(result.data.ivaName, result.data.ivaCode);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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;
blockers: Array<{
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;
}
export interface ResolveExistingProformaItemTaxesParams {
companyId: UniqueID;
itemsTaxes: Collection<ProformaItemTaxesProps>;
transaction?: unknown;
}
/**
* Resuelve datos fiscales externos al agregado de proforma usando servicios públicos de `catalogs`.
*
@ -136,6 +142,54 @@ export class ProformaTaxResolver {
});
}
public async resolveExistingItemTaxes(
params: ResolveExistingProformaItemTaxesParams
): Promise<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> {
const codes = [taxCodes.ivaCode, taxCodes.recCode, taxCodes.retentionCode]
.filter((code) => code.isSome())
@ -167,4 +221,13 @@ export class ProformaTaxResolver {
return Result.ok(Maybe.some(tax.data));
}
private replaceWithLiveTax(tax: Maybe<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-status-changer";
export * from "./proforma-target-invoice-series-validator.interface";
export * from "./proforma-tax-availability-checker";
export * from "./proforma-updater";

View File

@ -6,6 +6,7 @@ import type { Proforma } from "../../../domain";
import type { ProformaStatusCounts, ProformaSummary } from "../models";
import type { IProformaRepository } from "../repositories";
import type { ProformaListScope } from "../use-cases";
import type { ProformaTaxResolver } from "./catalog-resolver";
export interface IProformaFinder {
findProformaById(
@ -42,14 +43,20 @@ export interface IProformaFinder {
}
export class ProformaFinder implements IProformaFinder {
constructor(private readonly repository: IProformaRepository) {}
constructor(
private readonly deps: {
repository: IProformaRepository;
taxResolver: ProformaTaxResolver;
}
) {}
async findProformaById(
companyId: UniqueID,
proformaId: UniqueID,
transaction?: unknown
): Promise<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(
@ -57,7 +64,12 @@ export class ProformaFinder implements IProformaFinder {
proformaId: UniqueID,
transaction?: unknown
): 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(
@ -65,7 +77,7 @@ export class ProformaFinder implements IProformaFinder {
proformaId: UniqueID,
transaction?: unknown
): Promise<Result<boolean, Error>> {
return this.repository.existsByIdInCompany(companyId, proformaId, transaction);
return this.deps.repository.existsByIdInCompany(companyId, proformaId, transaction);
}
async findProformasByCriteria(
@ -74,7 +86,7 @@ export class ProformaFinder implements IProformaFinder {
criteria: Criteria,
transaction?: unknown
): Promise<Result<Collection<ProformaSummary>, Error>> {
return this.repository.findByCriteriaInCompany(companyId, scope, criteria, transaction);
return this.deps.repository.findByCriteriaInCompany(companyId, scope, criteria, transaction);
}
async countProformasByStatus(
@ -83,6 +95,28 @@ export class ProformaFinder implements IProformaFinder {
criteria: Criteria,
transaction?: unknown
): Promise<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 { CurrencyCode, LanguageCode, UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { Collection, Maybe, Result } from "@repo/rdx-utils";
import {
InvoiceNumber,
@ -89,6 +89,7 @@ const buildApprovedProforma = (params?: {
test("check devuelve canIssue true cuando la proforma y el payment method son válidos", async () => {
const checker = new ProformaIssuanceReadinessChecker({
taxAvailabilityChecker: { checkProformaTaxes: async () => Result.ok(new Collection([])) } as never,
paymentMethodFinder: {
findByIdInCompany: async () =>
Result.ok(
@ -117,6 +118,7 @@ test("check devuelve canIssue true cuando la proforma y el payment method son v
test("check acumula blockers internos sin abortar en el primero", async () => {
const checker = new ProformaIssuanceReadinessChecker({
taxAvailabilityChecker: { checkProformaTaxes: async () => Result.ok(new Collection([])) } as never,
paymentMethodFinder: {
findByIdInCompany: async () => Result.ok(Maybe.none()),
} as never,
@ -150,6 +152,7 @@ test("check acumula blockers internos sin abortar en el primero", async () => {
test("check informa payment_method_unavailable cuando el finder no resuelve la referencia company-scoped", async () => {
const checker = new ProformaIssuanceReadinessChecker({
taxAvailabilityChecker: { checkProformaTaxes: async () => Result.ok(new Collection([])) } as never,
paymentMethodFinder: {
findByIdInCompany: async () => Result.ok(Maybe.none()),
} as never,
@ -170,6 +173,7 @@ test("check informa payment_method_unavailable cuando el finder no resuelve la r
test("check informa payment_method_inactive cuando la referencia resuelta está inactiva", async () => {
const checker = new ProformaIssuanceReadinessChecker({
taxAvailabilityChecker: { checkProformaTaxes: async () => Result.ok(new Collection([])) } as never,
paymentMethodFinder: {
findByIdInCompany: async () =>
Result.ok(

View File

@ -4,6 +4,7 @@ import { Result } from "@repo/rdx-utils";
import type { Proforma } from "../../../domain";
import type { ProformaIssuanceReadinessModel } from "../models";
import type { ProformaTaxAvailabilityChecker } from "./proforma-tax-availability-checker";
export interface IProformaIssuanceReadinessChecker {
check(params: {
@ -17,6 +18,7 @@ export class ProformaIssuanceReadinessChecker implements IProformaIssuanceReadin
public constructor(
private readonly deps: {
paymentMethodFinder: IPaymentMethodPublicFinder;
taxAvailabilityChecker: ProformaTaxAvailabilityChecker;
}
) {}
@ -55,6 +57,23 @@ export class ProformaIssuanceReadinessChecker implements IProformaIssuanceReadin
}
}
const taxesResult = await this.deps.taxAvailabilityChecker.checkProformaTaxes(params);
if (taxesResult.isFailure) {
return Result.fail(taxesResult.error);
}
for (const entry of taxesResult.data.getAll()) {
if (entry.availability !== "available") {
blockers.push({
code: "tax_unavailable",
itemId: entry.itemId.toPrimitive(),
taxKind: entry.taxKind,
taxCode: entry.taxCode,
reason: entry.availability,
});
}
}
return Result.ok({
canIssue: blockers.length === 0,
blockers,

View File

@ -7,10 +7,11 @@ import type {
RecordAuditInput,
RecordAuditMutationShortcutInput,
} from "@repo/rdx-audit";
import { Result } from "@repo/rdx-utils";
import { Collection, Result } from "@repo/rdx-utils";
import { InvoiceStatus } from "../../../domain";
import { createProformaTestFixture } from "../../../domain/proformas/testing/proforma-test-fixture";
import { ProformaTaxesUnavailableError } from "../errors";
import { ProformaStatusChanger } from "./proforma-status-changer";
@ -45,6 +46,9 @@ test("cambia draft a sent y registra proforma.status_changed", async () => {
return Result.ok();
},
} as never,
taxAvailabilityChecker: {
checkProformaTaxes: async () => Result.ok(new Collection([])),
} as never,
audit,
});
@ -82,6 +86,9 @@ test("no persiste ni audita draft a draft", async () => {
return Result.ok();
},
} as never,
taxAvailabilityChecker: {
checkProformaTaxes: async () => Result.ok(new Collection([])),
} as never,
audit,
});
@ -110,6 +117,9 @@ test("rechaza una transición manual inválida sin persistir ni auditar", async
return Result.ok();
},
} as never,
taxAvailabilityChecker: {
checkProformaTaxes: async () => Result.ok(new Collection([])),
} as never,
audit,
});
@ -123,3 +133,50 @@ test("rechaza una transición manual inválida sin persistir ni auditar", async
assert.equal(updateCalls, 0);
assert.equal(audit.recordMutationCalls.length, 0);
});
test("rechaza impuestos no disponibles sin persistir ni auditar", async () => {
const proforma = createProformaTestFixture();
const audit = new FakeAudit();
let updateCalls = 0;
const changer = new ProformaStatusChanger({
repository: {
getByIdInCompany: async () => Result.ok(proforma),
update: async () => {
updateCalls += 1;
return Result.ok();
},
} as never,
taxAvailabilityChecker: {
checkProformaTaxes: async () =>
Result.ok(
new Collection([
{
itemId: proforma.id,
taxKind: "iva" as const,
taxCode: "iva_21",
availability: "inactive" as const,
},
{
itemId: proforma.id,
taxKind: "retention" as const,
taxCode: "retention_15",
availability: "missing" as const,
},
])
),
} as never,
audit,
});
const result = await changer.changeStatus({
companyId: proforma.companyId,
id: proforma.id,
newStatus: "sent",
});
assert.equal(result.isFailure, true);
assert.equal(result.error instanceof ProformaTaxesUnavailableError, true);
assert.equal((result.error as ProformaTaxesUnavailableError).entries.length, 2);
assert.equal(updateCalls, 0);
assert.equal(audit.recordMutationCalls.length, 0);
});

View File

@ -8,7 +8,9 @@ import {
toProformaAuditState,
toProformaAuditTarget,
} from "../audit/proforma-audit";
import { ProformaTaxesUnavailableError } from "../errors";
import type { IProformaRepository } from "../repositories";
import type { ProformaTaxAvailabilityChecker } from "./proforma-tax-availability-checker";
export interface IProformaStatusChanger {
changeStatus(params: {
@ -23,6 +25,7 @@ export class ProformaStatusChanger implements IProformaStatusChanger {
public constructor(
private readonly deps: {
repository: IProformaRepository;
taxAvailabilityChecker: ProformaTaxAvailabilityChecker;
audit: IAudit;
}
) {}
@ -50,8 +53,34 @@ export class ProformaStatusChanger implements IProformaStatusChanger {
}
const proforma = proformaResult.data;
const before = toProformaAuditState(proforma);
const transitionResult = proforma.ensureCanChangeStatus(statusResult.data);
if (transitionResult.isFailure) {
return Result.fail(transitionResult.error);
}
if (!transitionResult.data) {
return Result.ok(proforma);
}
const availabilityResult = await this.deps.taxAvailabilityChecker.checkProformaTaxes({
companyId: params.companyId,
proforma,
transaction: params.transaction,
});
if (availabilityResult.isFailure) {
return Result.fail(availabilityResult.error);
}
const unavailableTaxes = availabilityResult.data
.getAll()
.filter((entry) => entry.availability !== "available");
if (unavailableTaxes.length > 0) {
return Result.fail(new ProformaTaxesUnavailableError(unavailableTaxes));
}
const before = toProformaAuditState(proforma);
const changeResult = proforma.changeStatus(statusResult.data);
if (changeResult.isFailure) {

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

View File

@ -14,14 +14,17 @@ export class ProformaTaxesFullSnapshotBuilder implements IProformaTaxesFullSnaps
taxable_amount: proformaTax.taxableAmount.toObjectString(),
iva_code: maybeToNullable(proformaTax.ivaCode, (value) => value.toString()),
iva_name: maybeToNullable(proformaTax.ivaName, (value) => value.toString()),
iva_percentage: maybeToNullable(proformaTax.ivaPercentage, (value) => value.toObjectString()),
iva_amount: proformaTax.ivaAmount.toObjectString(),
rec_code: maybeToNullable(proformaTax.recCode, (value) => value.toString()),
rec_name: maybeToNullable(proformaTax.recName, (value) => value.toString()),
rec_percentage: maybeToNullable(proformaTax.recPercentage, (value) => value.toObjectString()),
rec_amount: proformaTax.recAmount.toObjectString(),
retention_code: maybeToNullable(proformaTax.retentionCode, (value) => value.toString()),
retention_name: maybeToNullable(proformaTax.retentionName, (value) => value.toString()),
retention_percentage: maybeToNullable(proformaTax.retentionPercentage, (value) =>
value.toObjectString()
),

View File

@ -16,6 +16,10 @@ export class ProformaIssuanceReadinessSnapshotBuilder
can_issue: source.canIssue,
blockers: source.blockers.map((blocker) => ({
code: blocker.code,
...(blocker.itemId ? { item_id: blocker.itemId } : {}),
...(blocker.taxKind ? { tax_kind: blocker.taxKind } : {}),
...(blocker.taxCode ? { tax_code: blocker.taxCode } : {}),
...(blocker.reason ? { reason: blocker.reason } : {}),
})),
};
}

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

View File

@ -7,9 +7,11 @@ import type { IIssuedInvoicePublicServices } from "../../issued-invoices";
import { toIssuedInvoiceAuditState } from "../../issued-invoices/audit/issued-invoice-audit";
import { VerifactuRecord, VerifactuRecordEstado } from "../../../domain";
import type { IssueProformaCommand } from "../commands";
import { ProformaNotReadyForIssuanceError } from "../errors";
import type { IProformaRepository } from "../repositories";
import type {
IProformaFinder,
IProformaIssuanceReadinessChecker,
IProformaIssueReadModelAssembler,
IProformaIssuer,
} from "../services";
@ -30,6 +32,7 @@ export class IssueProformaUseCase {
issuedInvoiceServices: IIssuedInvoicePublicServices;
audit: IAudit;
finder: IProformaFinder;
readinessChecker: IProformaIssuanceReadinessChecker;
issuer: IProformaIssuer;
issueReadModelAssembler: IProformaIssueReadModelAssembler;
issueResponseSnapshotBuilder: IIssueProformaResponseSnapshotBuilder;
@ -70,6 +73,18 @@ export class IssueProformaUseCase {
const proforma = proformaResult.data;
const readinessResult = await this.deps.readinessChecker.check({
companyId,
proforma,
transaction,
});
if (readinessResult.isFailure) {
return Result.fail(readinessResult.error);
}
if (!readinessResult.data.canIssue) {
return Result.fail(new ProformaNotReadyForIssuanceError(readinessResult.data.blockers));
}
// 2. Generamos la factura definitiva
const issuedInvoiceId = UniqueID.generateNewID();

View File

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

View File

@ -33,14 +33,17 @@ export interface IIssuedInvoiceItemCreateProps {
taxableAmount: ItemAmount;
ivaCode: Maybe<string>;
ivaName: Maybe<string>;
ivaPercentage: Maybe<DiscountPercentage>;
ivaAmount: ItemAmount;
recCode: Maybe<string>;
recName: Maybe<string>;
recPercentage: Maybe<DiscountPercentage>;
recAmount: ItemAmount;
retentionCode: Maybe<string>;
retentionName: Maybe<string>;
retentionPercentage: Maybe<DiscountPercentage>;
retentionAmount: ItemAmount;
@ -72,14 +75,17 @@ export interface IIssuedInvoiceItem {
taxableAmount: ItemAmount;
ivaCode: Maybe<string>;
ivaName: Maybe<string>;
ivaPercentage: Maybe<DiscountPercentage>;
ivaAmount: ItemAmount;
recCode: Maybe<string>;
recName: Maybe<string>;
recPercentage: Maybe<DiscountPercentage>;
recAmount: ItemAmount;
retentionCode: Maybe<string>;
retentionName: Maybe<string>;
retentionPercentage: Maybe<DiscountPercentage>;
retentionAmount: ItemAmount;
@ -100,6 +106,9 @@ export class IssuedInvoiceItem
props: IIssuedInvoiceItemCreateProps,
id?: UniqueID
): Result<IssuedInvoiceItem, Error> {
const taxNamesResult = validateTaxNames(props);
if (taxNamesResult.isFailure) return Result.fail(taxNamesResult.error);
const item = new IssuedInvoiceItem(props, id);
// Reglas de negocio / validaciones
@ -169,6 +178,9 @@ export class IssuedInvoiceItem
public get ivaCode(): Maybe<string> {
return this.props.ivaCode;
}
public get ivaName(): Maybe<string> {
return this.props.ivaName;
}
public get ivaPercentage(): Maybe<TaxPercentage> {
return this.props.ivaPercentage;
}
@ -179,6 +191,9 @@ export class IssuedInvoiceItem
public get recCode(): Maybe<string> {
return this.props.recCode;
}
public get recName(): Maybe<string> {
return this.props.recName;
}
public get recPercentage(): Maybe<TaxPercentage> {
return this.props.recPercentage;
}
@ -189,6 +204,9 @@ export class IssuedInvoiceItem
public get retentionCode(): Maybe<string> {
return this.props.retentionCode;
}
public get retentionName(): Maybe<string> {
return this.props.retentionName;
}
public get retentionPercentage(): Maybe<TaxPercentage> {
return this.props.retentionPercentage;
}
@ -216,3 +234,16 @@ export class IssuedInvoiceItem
return this.props.quantity.isSome() || this.props.unitAmount.isSome();
}
}
function validateTaxNames(props: IIssuedInvoiceItemCreateProps): Result<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;
ivaCode: string;
ivaName: string;
ivaPercentage: Percentage;
ivaAmount: InvoiceAmount;
recCode: Maybe<string>;
recName: Maybe<string>;
recPercentage: Maybe<Percentage>;
recAmount: InvoiceAmount;
retentionCode: Maybe<string>;
retentionName: Maybe<string>;
retentionPercentage: Maybe<Percentage>;
retentionAmount: InvoiceAmount;
@ -27,6 +30,13 @@ export class IssuedInvoiceTax extends DomainEntity<IssuedInvoiceTaxProps> {
props: IssuedInvoiceTaxProps,
id?: UniqueID
): 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));
}
@ -37,6 +47,9 @@ export class IssuedInvoiceTax extends DomainEntity<IssuedInvoiceTaxProps> {
public get ivaCode(): string {
return this.props.ivaCode;
}
public get ivaName(): string {
return this.props.ivaName;
}
public get ivaPercentage(): TaxPercentage {
return this.props.ivaPercentage;
}
@ -47,6 +60,9 @@ export class IssuedInvoiceTax extends DomainEntity<IssuedInvoiceTaxProps> {
public get recCode(): Maybe<string> {
return this.props.recCode;
}
public get recName(): Maybe<string> {
return this.props.recName;
}
public get recPercentage(): Maybe<TaxPercentage> {
return this.props.recPercentage;
}
@ -57,6 +73,9 @@ export class IssuedInvoiceTax extends DomainEntity<IssuedInvoiceTaxProps> {
public get retentionCode(): Maybe<string> {
return this.props.retentionCode;
}
public get retentionName(): Maybe<string> {
return this.props.retentionName;
}
public get retentionPercentage(): Maybe<TaxPercentage> {
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.
*/
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;
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);
}
@ -822,6 +835,34 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
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> {
const taxesResult = ProformaItemTaxes.create(props.taxes);
if (taxesResult.isFailure) return Result.fail(taxesResult.error);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -132,6 +132,8 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
errors
);
const ivaCode = maybeFromNullableOrEmptyString(raw.iva_code);
const ivaName = maybeFromNullableOrEmptyString(raw.iva_name);
ensureCodeAndName(ivaCode, ivaName, `items[${index}].iva`, errors);
const ivaPercentage = extractOrPushError(
maybeFromNullableResult(raw.iva_percentage_value, (value) => TaxPercentage.create({ value })),
`items[${index}].iva_percentage_value`,
@ -146,6 +148,8 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
errors
);
const recCode = maybeFromNullableOrEmptyString(raw.rec_code);
const recName = maybeFromNullableOrEmptyString(raw.rec_name);
ensureCodeAndName(recCode, recName, `items[${index}].rec`, errors);
const recPercentage = extractOrPushError(
maybeFromNullableResult(raw.rec_percentage_value, (value) => TaxPercentage.create({ value })),
`items[${index}].rec_percentage_value`,
@ -160,6 +164,8 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
errors
);
const retentionCode = maybeFromNullableOrEmptyString(raw.retention_code);
const retentionName = maybeFromNullableOrEmptyString(raw.retention_name);
ensureCodeAndName(retentionCode, retentionName, `items[${index}].retention`, errors);
const retentionPercentage = extractOrPushError(
maybeFromNullableResult(raw.retention_percentage_value, (value) =>
TaxPercentage.create({ value })
@ -207,12 +213,15 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
totalDiscountAmount,
taxableAmount,
ivaCode,
ivaName,
ivaPercentage,
ivaAmount,
recCode,
recName,
recPercentage,
recAmount,
retentionCode,
retentionName,
retentionPercentage,
retentionAmount,
taxesAmount,
@ -250,12 +259,15 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
totalDiscountAmount: attributes.totalDiscountAmount!,
taxableAmount: attributes.taxableAmount!,
ivaCode: attributes.ivaCode!,
ivaName: attributes.ivaName!,
ivaPercentage: attributes.ivaPercentage!,
ivaAmount: attributes.ivaAmount!,
recCode: attributes.recCode!,
recName: attributes.recName!,
recPercentage: attributes.recPercentage!,
recAmount: attributes.recAmount!,
retentionCode: attributes.retentionCode!,
retentionName: attributes.retentionName!,
retentionPercentage: attributes.retentionPercentage!,
retentionAmount: attributes.retentionAmount!,
taxesAmount: attributes.taxesAmount!,
@ -324,6 +336,7 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
// IVA
iva_code: maybeToNullableString(source.ivaCode),
iva_name: maybeToNullableString(source.ivaName),
iva_percentage_value: maybeToNullable(source.ivaPercentage, (v) => v.toPrimitive().value),
iva_percentage_scale:
@ -334,6 +347,7 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
// REC
rec_code: maybeToNullableString(source.recCode),
rec_name: maybeToNullableString(source.recName),
rec_percentage_value: maybeToNullable(source.recPercentage, (v) => v.toPrimitive().value),
rec_percentage_scale:
@ -344,6 +358,7 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
// RET
retention_code: maybeToNullableString(source.retentionCode),
retention_name: maybeToNullableString(source.retentionName),
retention_percentage_value: maybeToNullable(
source.retentionPercentage,
@ -365,3 +380,14 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
} satisfies IssuedInvoiceItemCreationAttributes);
}
}
function ensureCodeAndName(
code: ReturnType<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
);
const ivaCode = raw.iva_code;
const ivaName = raw.iva_name;
if (typeof ivaCode !== "string" || ivaCode.length === 0 || typeof ivaName !== "string" || ivaName.length === 0) {
errors.push({ path: `taxes[${index}].iva`, message: "tax code and name must be present together" });
}
const ivaPercentage = extractOrPushError(
TaxPercentage.create({ value: Number(raw.iva_percentage_value) }),
`taxes[${index}].iva_percentage_value`,
@ -70,6 +74,8 @@ export class SequelizeIssuedInvoiceV2TaxesDomainMapper extends SequelizeDomainMa
errors
);
const recCode = maybeFromNullableOrEmptyString(raw.rec_code);
const recName = maybeFromNullableOrEmptyString(raw.rec_name);
ensureCodeAndName(recCode, recName, `taxes[${index}].rec`, errors);
const recPercentage = extractOrPushError(
maybeFromNullableResult(raw.rec_percentage_value, (value) => TaxPercentage.create({ value })),
`taxes[${index}].rec_percentage_value`,
@ -81,6 +87,8 @@ export class SequelizeIssuedInvoiceV2TaxesDomainMapper extends SequelizeDomainMa
errors
);
const retentionCode = maybeFromNullableOrEmptyString(raw.retention_code);
const retentionName = maybeFromNullableOrEmptyString(raw.retention_name);
ensureCodeAndName(retentionCode, retentionName, `taxes[${index}].retention`, errors);
const retentionPercentage = extractOrPushError(
maybeFromNullableResult(raw.retention_percentage_value, (value) => TaxPercentage.create({ value })),
`taxes[${index}].retention_percentage_value`,
@ -109,12 +117,15 @@ export class SequelizeIssuedInvoiceV2TaxesDomainMapper extends SequelizeDomainMa
return IssuedInvoiceTax.create({
taxableAmount: taxableAmount!,
ivaCode: ivaCode!,
ivaName: ivaName!,
ivaPercentage: ivaPercentage!,
ivaAmount: ivaAmount!,
recCode,
recName,
recPercentage: recPercentage!,
recAmount: recAmount!,
retentionCode,
retentionName,
retentionPercentage: retentionPercentage!,
retentionAmount: retentionAmount!,
taxesAmount: taxesAmount!,
@ -137,11 +148,13 @@ export class SequelizeIssuedInvoiceV2TaxesDomainMapper extends SequelizeDomainMa
taxable_amount_value: source.taxableAmount.value,
taxable_amount_scale: source.taxableAmount.scale,
iva_code: source.ivaCode,
iva_name: source.ivaName,
iva_percentage_value: source.ivaPercentage.value,
iva_percentage_scale: source.ivaPercentage.scale,
iva_amount_value: source.ivaAmount.value,
iva_amount_scale: source.ivaAmount.scale,
rec_code: maybeToNullableString(source.recCode),
rec_name: maybeToNullableString(source.recName),
rec_percentage_value: maybeToNullable(source.recPercentage, (v) => v.toPrimitive().value),
rec_percentage_scale:
maybeToNullable(source.recPercentage, (v) => v.toPrimitive().scale) ??
@ -149,6 +162,7 @@ export class SequelizeIssuedInvoiceV2TaxesDomainMapper extends SequelizeDomainMa
rec_amount_value: source.recAmount.toPrimitive().value,
rec_amount_scale: source.recAmount.toPrimitive().scale ?? ItemAmount.DEFAULT_SCALE,
retention_code: maybeToNullableString(source.retentionCode),
retention_name: maybeToNullableString(source.retentionName),
retention_percentage_value: maybeToNullable(
source.retentionPercentage,
(v) => v.toPrimitive().value
@ -167,3 +181,14 @@ export class SequelizeIssuedInvoiceV2TaxesDomainMapper extends SequelizeDomainMa
}
}
}
function ensureCodeAndName(
code: ReturnType<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,
});
const { repository, numberGenerator: numberService } = persistence;
const finder = buildProformaFinder(repository);
const catalogResolvers = buildProformaCatalogResolvers({
taxDefinitionFinder: catalogs.taxDefinition.finder,
@ -75,6 +74,7 @@ export function buildProformaPublicServices(
paymentMethodFinder: catalogs.paymentMethod.finder,
paymentTermFinder: catalogs.paymentTerm.finder,
});
const finder = buildProformaFinder({ repository, taxResolver: catalogResolvers.taxResolver });
const readModelAssemblers = buildProformaReadModelAssemblers({
paymentMethodFinder: catalogs.paymentMethod.finder,

View File

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

View File

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

View File

@ -14,8 +14,12 @@ import {
InvoiceSeriesNotFoundError,
InvoiceSeriesTransactionRequiredError,
IssuedInvoiceNumberAlreadyExistsError,
ProformaTaxesUnavailableError,
ProformaNotReadyForIssuanceError,
isCompanyReportProfileNotFoundError,
isIssuedInvoiceNumberAlreadyExistsError,
isProformaTaxesUnavailableError,
isProformaNotReadyForIssuanceError,
} from "../../../application";
import {
type CustomerInvoiceIdAlreadyExistsError,
@ -78,6 +82,22 @@ const proformaTransitionRule: ErrorToApiRule = {
),
};
const proformaTaxesUnavailableRule: ErrorToApiRule = {
priority: 120,
matches: (e) => isProformaTaxesUnavailableError(e),
build: (e) =>
new ConflictApiError(
(e as ProformaTaxesUnavailableError).message ||
"Proforma status cannot be changed while applied taxes are unavailable."
),
};
const proformaNotReadyForIssuanceRule: ErrorToApiRule = {
priority: 120,
matches: (e) => isProformaNotReadyForIssuanceError(e),
build: (e) => new ConflictApiError((e as ProformaNotReadyForIssuanceError).message),
};
const proformaConversionRule: ErrorToApiRule = {
priority: 120,
matches: (e) => isProformaCannotBeConvertedToInvoiceError(e),
@ -227,4 +247,6 @@ export const proformasApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default()
.register(proformaCannotBeUnarchivedRule)
.register(proformaCannotBeArchivedRule)
.register(proformaCannotBeDeletedRule)
.register(proformaTaxesUnavailableRule)
.register(proformaNotReadyForIssuanceRule)
.register(proformaTransitionRule);

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

View File

@ -11,10 +11,15 @@ export const ProformaIssuanceBlockerCodeSchema = z.enum([
"missing_tax_regime",
"payment_method_unavailable",
"payment_method_inactive",
"tax_unavailable",
]);
export const ProformaIssuanceBlockerSchema = z.object({
code: ProformaIssuanceBlockerCodeSchema,
item_id: z.uuid().optional(),
tax_kind: z.enum(["iva", "rec", "retention"]).optional(),
tax_code: z.string().optional(),
reason: z.enum(["inactive", "out_of_validity", "missing"]).optional(),
});
export const GetProformaIssuanceReadinessResponseSchema = z.object({

View File

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