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:
parent
396feb4fa4
commit
3d8f110cb7
@ -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;
|
||||
|
||||
|
||||
@ -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>>;
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
|
||||
@ -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 };
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 };
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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";
|
||||
}>;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
),
|
||||
|
||||
@ -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()
|
||||
),
|
||||
|
||||
@ -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 } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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> =>
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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/);
|
||||
});
|
||||
@ -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,
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: {},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: {},
|
||||
|
||||
@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
@ -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
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user