feat: Refactor tax handling in proforma and recurring proforma modules

- Introduced ProformaItemTax value object to encapsulate tax properties (code, name, percentage).
- Updated ProformaTaxResolver to utilize ProformaItemTax instead of Tax.
- Modified recurring proforma materializer to handle new tax structure.
- Adjusted proforma aggregate and item entities to accommodate ProformaItemTax.
- Enhanced Sequelize mappers to map to and from the new ProformaItemTax structure.
- Updated tests to reflect changes in tax handling and ensure correctness.
This commit is contained in:
David Arranz 2026-09-10 13:21:49 +02:00
parent e94b1dd7bd
commit 0a2e7795f1
19 changed files with 343 additions and 184 deletions

View File

@ -29,14 +29,17 @@ protección transversal consolidada que impida borrar una `TaxDefinition` por es
## Fiscalidad viva de Proforma
Una Proforma es fiscalmente viva. Al cargarla, si existe la `TaxDefinition` de la empresa para
un código, prevalece la definición actual: `code`, `name`, porcentaje/rate, family/group y
`calculationBehavior`. Por ejemplo, una línea persistida como `IVA21` al 21 % se presenta al
22 % si la definición viva `IVA21` se actualizó al 22 %.
`Tax` es el objeto operacional obtenido del catálogo: contiene `code`, `name`, `rate`, `group`
y `calculationBehavior`. Antes de entrar en una línea de Proforma se convierte explícitamente a
`ProformaItemTax`, el snapshot fiscal de la línea: `code`, `name` y `percentage`.
La línea conserva además un respaldo persistido completo: código, nombre, porcentaje, group y
calculation behavior (más los importes calculados). Ese respaldo no es fuente funcional primaria
cuando existe catálogo vivo; sirve para recuperación, diagnóstico y lectura ante inconsistencias.
Al cargar una Proforma, si existe la `TaxDefinition` para un código, prevalece su definición viva
y se vuelve a materializar como `ProformaItemTax`. Si falta, se conserva el snapshot persistido
(`code`, `name`, `percentage`) para que la Proforma siga siendo legible. Los importes no forman
parte de `ProformaItemTax`: se derivan siempre de `taxableAmount × percentage`.
`group` y `calculationBehavior` pertenecen exclusivamente a `Tax`/`TaxDefinition` operacional;
no se persisten en `ProformaItem`.
## Disponibilidad fiscal
@ -45,7 +48,7 @@ La disponibilidad se evalúa por `companyId`, código fiscal y `proformaDate`:
- `available`: puede asignarse, cambiar de estado y emitirse.
- `inactive` o `out_of_validity`: una lectura existente se resuelve live, pero no admite nueva
asignación, cambio de estado ni emisión.
- `missing`: la lectura hace fallback al Tax persistido; no permite nueva asignación, cambio de
- `missing`: la lectura hace fallback al `ProformaItemTax` persistido; no permite nueva asignación, cambio de
estado ni emisión.
No hay fallback a defaults de Customer o Company. Readiness expone blockers `tax_unavailable`

View File

@ -114,24 +114,18 @@ CREATE TABLE IF NOT EXISTS proforma_items (
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,

View File

@ -8,6 +8,8 @@ import { Collection, Maybe, Result } from "@repo/rdx-utils";
import type { TaxDefinitionToTaxMapper } from "../../../common/mappers";
import { ProformaItemTax } from "../../../../domain";
import { ProformaTaxResolver } from "./proforma-tax-resolver";
const required = <T>(result: { data: T }): T => result.data;
@ -33,7 +35,7 @@ const buildResolver = (liveTaxes: Tax[]) => {
const finder = {
findByCodes: async () =>
Result.ok(
new Collection(liveTaxes.map((tax) => ({ code: tax.code } as TaxDefinitionPublicModel)))
new Collection(liveTaxes.map((tax) => ({ code: tax.code }) as TaxDefinitionPublicModel))
),
} as unknown as ITaxDefinitionPublicFinder;
const taxMapper = {
@ -84,24 +86,27 @@ test("ProformaTaxResolver sustituye definiciones vivas y conserva las missing",
group: "retention",
calculationBehavior: "subtractive",
});
const snapshot = (tax: Tax) =>
required(ProformaItemTax.create({ code: tax.code, name: tax.name, percentage: tax.rate }));
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),
iva: Maybe.some(snapshot(persistedIva)),
rec: Maybe.some(snapshot(persistedRec)),
retention: Maybe.some(snapshot(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);
assert.equal(taxes.iva.unwrap().code, liveIva.code);
assert.equal(taxes.iva.unwrap().name, liveIva.name);
assert.equal(taxes.rec.unwrap().name, persistedRec.name);
assert.equal(taxes.retention.unwrap().name, liveRetention.name);
assert.equal(taxes.iva.unwrap().percentage.value, 2200);
assert.notEqual(taxes.iva.unwrap().percentage.value, persistedIva.value);
});

View File

@ -7,7 +7,11 @@ import type { Tax } from "@erp/core/api";
import type { UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Collection, Maybe, Result } from "@repo/rdx-utils";
import { ProformaItemTaxes, type ProformaItemTaxesProps } from "../../../../domain";
import {
ProformaItemTax,
ProformaItemTaxes,
type ProformaItemTaxesProps,
} from "../../../../domain";
import type { TaxDefinitionToTaxMapper } from "../../../common/mappers";
import type { ProformaItemTaxCodesInput } from "../../models";
@ -170,13 +174,15 @@ export class ProformaTaxResolver {
return Result.fail(definitions.error);
}
const liveTaxesByCode = new Map<string, Tax>();
const liveTaxesByCode = new Map<string, ProformaItemTax>();
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);
const proformaTaxResult = this.toProformaItemTax(taxResult.data);
if (proformaTaxResult.isFailure) return Result.fail(proformaTaxResult.error);
liveTaxesByCode.set(proformaTaxResult.data.code, proformaTaxResult.data);
}
return Result.ok(
@ -201,7 +207,7 @@ export class ProformaTaxResolver {
private toMaybeTax(
taxDefinitions: Collection<TaxDefinitionPublicModel>,
code: Maybe<string>
): Result<Maybe<Tax>, Error> {
): Result<Maybe<ProformaItemTax>, Error> {
if (code.isNone()) {
return Result.ok(Maybe.none());
}
@ -219,10 +225,19 @@ export class ProformaTaxResolver {
return Result.fail(tax.error);
}
return Result.ok(Maybe.some(tax.data));
const proformaTax = this.toProformaItemTax(tax.data);
if (proformaTax.isFailure) return Result.fail(proformaTax.error);
return Result.ok(Maybe.some(proformaTax.data));
}
private replaceWithLiveTax(tax: Maybe<Tax>, liveTaxesByCode: Map<string, Tax>): Maybe<Tax> {
private toProformaItemTax(tax: Tax): Result<ProformaItemTax, Error> {
return ProformaItemTax.create({ code: tax.code, name: tax.name, percentage: tax.rate });
}
private replaceWithLiveTax(
tax: Maybe<ProformaItemTax>,
liveTaxesByCode: Map<string, ProformaItemTax>
): Maybe<ProformaItemTax> {
if (tax.isNone()) {
return tax;
}

View File

@ -1,18 +1,22 @@
import type { ICompanyPublicServices } from "@erp/companies/api";
import type { ICustomerPublicServices } from "@erp/customers/api";
import { DiscountPercentage } from "@erp/core/api";
import type { ICustomerPublicServices } from "@erp/customers/api";
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { InvoiceStatus, ProformaRecipient, type RecurringProforma } from "../../../domain";
import type { ProformaCreateInputProps } from "../../proformas/models";
import type { IProformaCreator } from "../../proformas/services/proforma-creator";
import type { ProformaPaymentResolver } from "../../proformas/services/catalog-resolver/proforma-payment-resolver";
import type { ProformaTaxConfigResolver } from "../../proformas/services/catalog-resolver/proforma-tax-config-resolver";
import type { ProformaTaxResolver } from "../../proformas/services/catalog-resolver/proforma-tax-resolver";
import type { IProformaCreator } from "../../proformas/services/proforma-creator";
import type { ProformaSeriesValidator } from "../../proformas/services/proforma-series-validator.interface";
import type { ProformaTargetInvoiceSeriesValidator } from "../../proformas/services/proforma-target-invoice-series-validator.interface";
/**
* Genera una proforma a partir de una RecurringProforma"
*/
export class RecurringProformaMaterializer {
public constructor(
private readonly deps: {
@ -35,9 +39,11 @@ export class RecurringProformaMaterializer {
transaction: unknown;
}): Promise<Result<{ proformaId: UniqueID }, Error>> {
const customerId = params.recurringProforma.customerId;
if (customerId.isNone()) return Result.fail(new Error("Recurring proforma customer is required."));
if (customerId.isNone())
return Result.fail(new Error("Recurring proforma customer is required."));
const customerResult = await this.deps.customerServices.getCustomerById(customerId.unwrap(), {
companyId: params.companyId, transaction: params.transaction,
companyId: params.companyId,
transaction: params.transaction,
});
if (customerResult.isFailure) return Result.fail(customerResult.error);
const customer = customerResult.data;
@ -46,38 +52,135 @@ export class RecurringProformaMaterializer {
const paymentTerm = await this.resolvePaymentTerm(params.recurringProforma, customer, params);
if (paymentTerm.isFailure) return Result.fail(paymentTerm.error);
const paymentMethod = params.recurringProforma.paymentMethodId.isSome()
? params.recurringProforma.paymentMethodId : customer.paymentMethodId;
const paymentMethodValidation = await this.deps.paymentResolver.ensurePaymentMethodById({ companyId: params.companyId, id: paymentMethod, transaction: params.transaction });
? params.recurringProforma.paymentMethodId
: customer.paymentMethodId;
const paymentMethodValidation = await this.deps.paymentResolver.ensurePaymentMethodById({
companyId: params.companyId,
id: paymentMethod,
transaction: params.transaction,
});
if (paymentMethodValidation.isFailure) return Result.fail(paymentMethodValidation.error);
const languageCode = params.recurringProforma.language.isSome() ? params.recurringProforma.language.unwrap() : customer.languageCode;
const currencyCode = params.recurringProforma.currency.isSome() ? params.recurringProforma.currency.unwrap() : customer.currencyCode;
const languageCode = params.recurringProforma.language.isSome()
? params.recurringProforma.language.unwrap()
: customer.languageCode;
const currencyCode = params.recurringProforma.currency.isSome()
? params.recurringProforma.currency.unwrap()
: customer.currencyCode;
const series = await this.validateSeries(params.recurringProforma, params);
if (series.isFailure) return Result.fail(series.error);
const taxConfig = await this.deps.taxConfigResolver.resolve({ companyId: params.companyId, atDate: date.data, customer, transaction: params.transaction });
const taxConfig = await this.deps.taxConfigResolver.resolve({
companyId: params.companyId,
atDate: date.data,
customer,
transaction: params.transaction,
});
if (taxConfig.isFailure) return Result.fail(taxConfig.error);
const items: ProformaCreateInputProps["items"] = [];
for (const item of params.recurringProforma.items.getAll()) {
const taxes = await this.deps.taxResolver.ensureItemTaxes({ companyId: params.companyId, atDate: date.data, transaction: params.transaction, taxCodes: { ivaCode: item.ivaCode, recCode: item.recCode, retentionCode: item.retentionCode } });
const taxes = await this.deps.taxResolver.ensureItemTaxes({
companyId: params.companyId,
atDate: date.data,
transaction: params.transaction,
taxCodes: {
ivaCode: item.ivaCode,
recCode: item.recCode,
retentionCode: item.retentionCode,
},
});
if (taxes.isFailure) return Result.fail(taxes.error);
items.push({ description: item.description, quantity: item.quantity, unitAmount: item.unitAmount, itemDiscountPercentage: item.itemDiscountPercentage, taxes: { ivaCode: item.ivaCode, recCode: item.recCode, retentionCode: item.retentionCode }, languageCode, currencyCode, globalDiscountPercentage: params.recurringProforma.globalDiscountPercentage.isSome() ? params.recurringProforma.globalDiscountPercentage.unwrap() : DiscountPercentage.zero() });
items.push({
description: item.description,
quantity: item.quantity,
unitAmount: item.unitAmount,
itemDiscountPercentage: item.itemDiscountPercentage,
taxes: { ivaCode: item.ivaCode, recCode: item.recCode, retentionCode: item.retentionCode },
languageCode,
currencyCode,
globalDiscountPercentage: params.recurringProforma.globalDiscountPercentage.isSome()
? params.recurringProforma.globalDiscountPercentage.unwrap()
: DiscountPercentage.zero(),
});
}
const recipient = ProformaRecipient.create({ name: customer.name, tin: customer.tin, street: customer.address.street, street2: customer.address.street2, city: customer.address.city, postalCode: customer.address.postalCode, province: customer.address.province, country: customer.address.country });
const recipient = ProformaRecipient.create({
name: customer.name,
tin: customer.tin,
street: customer.address.street,
street2: customer.address.street2,
city: customer.address.city,
postalCode: customer.address.postalCode,
province: customer.address.province,
country: customer.address.countryCode,
});
if (recipient.isFailure) return Result.fail(recipient.error);
const status = InvoiceStatus.create("draft");
if (status.isFailure) return Result.fail(status.error);
const created = await this.deps.proformaCreator.create({ companyId: params.companyId, id: UniqueID.generateNewID(), transaction: params.transaction, props: { companyId: params.companyId, status: status.data, proformaSeriesCode: params.recurringProforma.proformaSeries, targetInvoiceSeriesCode: params.recurringProforma.issuedInvoiceSeries, proformaDate: date.data, operationDate: Maybe.some(date.data), customerId: customer.id, recipient: Maybe.some(recipient.data), reference: Maybe.none(), description: params.recurringProforma.description, notes: Maybe.none(), languageCode, currencyCode, linkedInvoiceId: Maybe.none(), paymentMethodId: paymentMethod, taxRegimeCode: customer.taxRegimeCode, taxConfig: taxConfig.data.getProps(), globalDiscountPercentage: params.recurringProforma.globalDiscountPercentage.isSome() ? params.recurringProforma.globalDiscountPercentage.unwrap() : DiscountPercentage.zero(), items } });
return created.isFailure ? Result.fail(created.error) : Result.ok({ proformaId: created.data.id });
const created = await this.deps.proformaCreator.create({
companyId: params.companyId,
id: UniqueID.generateNewID(),
transaction: params.transaction,
props: {
companyId: params.companyId,
status: status.data,
proformaSeriesCode: params.recurringProforma.proformaSeries,
targetInvoiceSeriesCode: params.recurringProforma.issuedInvoiceSeries,
proformaDate: date.data,
operationDate: Maybe.some(date.data),
customerId: customer.id,
recipient: Maybe.some(recipient.data),
reference: Maybe.none(),
description: params.recurringProforma.description,
notes: Maybe.none(),
languageCode,
currencyCode,
linkedInvoiceId: Maybe.none(),
paymentMethodId: paymentMethod,
taxRegimeCode: customer.taxRegimeCode,
taxConfig: taxConfig.data.getProps(),
globalDiscountPercentage: params.recurringProforma.globalDiscountPercentage.isSome()
? params.recurringProforma.globalDiscountPercentage.unwrap()
: DiscountPercentage.zero(),
items,
},
});
return created.isFailure
? Result.fail(created.error)
: Result.ok({ proformaId: created.data.id });
}
private async validateSeries(recurring: RecurringProforma, params: { companyId: UniqueID; transaction: unknown }): Promise<Result<void, Error>> {
const proforma = await this.deps.proformaSeriesValidator.ensureValidProformaSeries({ companyId: params.companyId, proformaSeriesCode: recurring.proformaSeries, transaction: params.transaction });
private async validateSeries(
recurring: RecurringProforma,
params: { companyId: UniqueID; transaction: unknown }
): Promise<Result<void, Error>> {
const proforma = await this.deps.proformaSeriesValidator.ensureValidProformaSeries({
companyId: params.companyId,
proformaSeriesCode: recurring.proformaSeries,
transaction: params.transaction,
});
if (proforma.isFailure) return Result.fail(proforma.error);
return this.deps.targetInvoiceSeriesValidator.ensureValidIssuedInvoiceSeries({ companyId: params.companyId, targetInvoiceSeriesCode: recurring.issuedInvoiceSeries, transaction: params.transaction });
return this.deps.targetInvoiceSeriesValidator.ensureValidIssuedInvoiceSeries({
companyId: params.companyId,
targetInvoiceSeriesCode: recurring.issuedInvoiceSeries,
transaction: params.transaction,
});
}
private async resolvePaymentTerm(recurring: RecurringProforma, customer: { paymentTermId: Maybe<UniqueID> }, params: { companyId: UniqueID; transaction: unknown }): Promise<Result<Maybe<UniqueID>, Error>> {
const selected = recurring.paymentTermId.isSome() ? recurring.paymentTermId : customer.paymentTermId;
const validation = await this.deps.paymentResolver.ensurePaymentTermById({ companyId: params.companyId, id: selected, transaction: params.transaction });
private async resolvePaymentTerm(
recurring: RecurringProforma,
customer: { paymentTermId: Maybe<UniqueID> },
params: { companyId: UniqueID; transaction: unknown }
): Promise<Result<Maybe<UniqueID>, Error>> {
const selected = recurring.paymentTermId.isSome()
? recurring.paymentTermId
: customer.paymentTermId;
const validation = await this.deps.paymentResolver.ensurePaymentTermById({
companyId: params.companyId,
id: selected,
transaction: params.transaction,
});
return validation.isFailure ? Result.fail(validation.error) : Result.ok(selected);
}
}

View File

@ -1,4 +1,4 @@
import type { DiscountPercentage, Tax } from "@erp/core/api";
import type { DiscountPercentage } from "@erp/core/api";
import {
AggregateRoot,
type CurrencyCode,
@ -41,6 +41,7 @@ import type { IProformaTaxTotals, ProformaCalculationContext } from "../services
import { canManuallyTransitionProformaStatus } from "../services";
import {
ProformaItemTaxes,
type ProformaItemTax,
type ProformaItemTaxesProps,
type ProformaRecipient,
type ProformaTaxConfig,
@ -408,14 +409,8 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
);
}
private sameTax(left: Tax, right: Tax): boolean {
return (
left.code === right.code &&
left.name === right.name &&
left.rate.equals(right.rate) &&
left.group === right.group &&
left.calculationBehavior === right.calculationBehavior
);
private sameTax(left: ProformaItemTax, right: ProformaItemTax): boolean {
return left.equals(right);
}
private sameMaybe<T>(
@ -852,12 +847,7 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
return Result.fail(taxesResult.error);
}
items.push(
ProformaItem.rehydrate(
{ ...item.getProps(), taxes: taxesResult.data },
item.id
)
);
items.push(ProformaItem.rehydrate({ ...item.getProps(), taxes: taxesResult.data }, item.id));
}
return Result.ok(Proforma.rehydrate(this.props, ProformaItems.create({ items }), this.id));

View File

@ -1,13 +1,14 @@
import { DiscountPercentage, type Tax, type TaxPercentage } from "@erp/core/api";
import { DiscountPercentage, type TaxPercentage } from "@erp/core/api";
import { DomainEntity, type UniqueID } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import { ItemAmount, type ItemDescription, type ItemQuantity } from "../../../common";
import type { ProformaCalculationContext } from "../../services";
import {
type ProformaItemTax,
ProformaItemTaxes,
type ProformaItemTaxesProps,
} from "../../value-objects/proforma-item-taxes.vo";
} from "../../value-objects";
/**
*
@ -166,7 +167,7 @@ export class ProformaItem extends DomainEntity<InternalProformaItemProps> implem
return unitAmount.multiply(quantity);
}
public iva(): Maybe<Tax> {
public iva(): Maybe<ProformaItemTax> {
return this.taxes.iva;
}
@ -178,7 +179,7 @@ export class ProformaItem extends DomainEntity<InternalProformaItemProps> implem
return this.taxes.iva.map((tax) => tax.percentage);
}
public rec(): Maybe<Tax> {
public rec(): Maybe<ProformaItemTax> {
return this.taxes.rec;
}
@ -190,7 +191,7 @@ export class ProformaItem extends DomainEntity<InternalProformaItemProps> implem
return this.taxes.rec.map((tax) => tax.percentage);
}
public retention(): Maybe<Tax> {
public retention(): Maybe<ProformaItemTax> {
return this.taxes.retention;
}

View File

@ -1,8 +1,9 @@
import type { Tax, TaxPercentage } from "@erp/core/api";
import type { TaxPercentage } from "@erp/core/api";
import { Maybe } from "@repo/rdx-utils";
import { ItemAmount } from "../../common";
import type { IProformaItems } from "../entities";
import type { ProformaItemTax } from "../value-objects";
import type { ProformaCalculationContext } from "./proforma-items-totals-calculator";
@ -97,7 +98,7 @@ export function proformaComputeTaxGroups(
function ensureTaxNameMatches(
code: Maybe<string>,
name: Maybe<string>,
tax: Maybe<Tax>,
tax: Maybe<ProformaItemTax>,
kind: string
): void {
if (code.isNone() || tax.isNone()) return;
@ -109,7 +110,7 @@ function ensureTaxNameMatches(
function ensureTaxPercentageMatches(
code: Maybe<string>,
percentage: Maybe<TaxPercentage>,
tax: Maybe<Tax>,
tax: Maybe<ProformaItemTax>,
kind: string
): void {
if (code.isNone() || tax.isNone()) return;
@ -123,7 +124,11 @@ function ensureTaxPercentageMatches(
}
}
function buildTaxGroupKey(iva: Maybe<Tax>, rec: Maybe<Tax>, retention: Maybe<Tax>): string {
function buildTaxGroupKey(
iva: Maybe<ProformaItemTax>,
rec: Maybe<ProformaItemTax>,
retention: Maybe<ProformaItemTax>
): string {
const ivaPart = iva.isSome() ? `${iva.unwrap().code}` : "NULL";
const recPart = rec.isSome() ? `${rec.unwrap().code}` : "NULL";
const retentionPart = retention.isSome() ? `${retention.unwrap().code}` : "NULL";

View File

@ -1,11 +1,12 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Tax, TaxPercentage } from "@erp/core/api";
import { TaxPercentage } from "@erp/core/api";
import { Maybe } from "@repo/rdx-utils";
import { ItemAmount, ItemQuantity } from "../../common";
import type { IProformaItemCreateProps } from "../entities";
import { ProformaItemTax } from "../value-objects";
import { createProformaTestFixture } from "../testing/proforma-test-fixture";
const required = <T>(result: { data: T }): T => result.data;
@ -18,12 +19,10 @@ const createTax = (params: {
calculationBehavior: "additive" | "subtractive";
}) =>
required(
Tax.create({
ProformaItemTax.create({
code: params.code,
name: params.name,
rate: required(TaxPercentage.create({ value: params.value })),
group: params.group,
calculationBehavior: params.calculationBehavior,
percentage: required(TaxPercentage.create({ value: params.value })),
})
);

View File

@ -1,3 +1,4 @@
export * from "./proforma-item-tax.vo";
export * from "./proforma-item-taxes.vo";
export * from "./proforma-recipient.vo";
export * from "./proforma-tax-config.vo";

View File

@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import test from "node:test";
import { TaxPercentage } from "@erp/core/api";
import { ProformaItemTax } from "./proforma-item-tax.vo";
const required = <T>(result: { data: T }): T => result.data;
test("ProformaItemTax compara code, name y percentage", () => {
const percentage = required(TaxPercentage.create({ value: 2100 }));
const tax = required(ProformaItemTax.create({ code: "iva_21", name: "IVA", percentage }));
const equal = required(ProformaItemTax.create({ code: "iva_21", name: "IVA", percentage }));
const renamed = required(
ProformaItemTax.create({ code: "iva_21", name: "IVA general", percentage })
);
assert.equal(tax.equalsTo(equal), true);
assert.equal(tax.equalsTo(renamed), false);
});

View File

@ -0,0 +1,39 @@
import type { TaxPercentage } from "@erp/core/api";
import { ValueObject } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
export type ProformaItemTaxProps = {
code: string;
name: string;
percentage: TaxPercentage;
};
export class ProformaItemTax extends ValueObject<ProformaItemTaxProps> {
static create(props: ProformaItemTaxProps): Result<ProformaItemTax, Error> {
return Result.ok(new ProformaItemTax(props));
}
public equalsTo(other: ProformaItemTax): boolean {
return (
this.code === other.code &&
this.name === other.name &&
this.percentage.equals(other.percentage)
);
}
public get code(): string {
return this.props.code;
}
public get name(): string {
return this.props.name;
}
public get percentage(): TaxPercentage {
return this.props.percentage;
}
public getProps(): ProformaItemTaxProps {
return this.props;
}
public toPrimitive(): ProformaItemTaxProps {
return this.props;
}
}

View File

@ -1,19 +1,20 @@
import type { Tax } from "@erp/core/api";
import { ValueObject } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { ItemAmount } from "../../common/value-objects";
import type { ProformaItemTax } from "./proforma-item-tax.vo";
export type ProformaItemTaxesProps = {
iva: Maybe<Tax>; // si existe
rec: Maybe<Tax>; // si existe
retention: Maybe<Tax>; // si existe
iva: Maybe<ProformaItemTax>; // si existe
rec: Maybe<ProformaItemTax>; // si existe
retention: Maybe<ProformaItemTax>; // si existe
};
export interface IProformaItemTaxes {
iva: Maybe<Tax>; // si existe
rec: Maybe<Tax>; // si existe
retention: Maybe<Tax>; // si existe
iva: Maybe<ProformaItemTax>; // si existe
rec: Maybe<ProformaItemTax>; // si existe
retention: Maybe<ProformaItemTax>; // si existe
toKey(): string; // Clave para representar un trío.
@ -83,15 +84,15 @@ export class ProformaItemTaxes
return { ivaAmount, recAmount, retentionAmount };
}
get iva(): Maybe<Tax> {
get iva(): Maybe<ProformaItemTax> {
return this.props.iva;
}
get rec(): Maybe<Tax> {
get rec(): Maybe<ProformaItemTax> {
return this.props.rec;
}
get retention(): Maybe<Tax> {
get retention(): Maybe<ProformaItemTax> {
return this.props.retention;
}

View File

@ -110,10 +110,7 @@ export class RecurrenceSchedule extends ValueObject<RecurrenceScheduleProps> {
const occurrenceDate = this.calculateOccurrenceAt(index);
if (
this.props.endDate !== null &&
occurrenceDate.getTime() > this.props.endDate.getTime()
) {
if (this.props.endDate !== null && occurrenceDate.getTime() > this.props.endDate.getTime()) {
return Result.ok(null);
}
@ -130,6 +127,10 @@ export class RecurrenceSchedule extends ValueObject<RecurrenceScheduleProps> {
};
}
public toPrimitive(): RecurrenceScheduleProps {
return this.getProps();
}
private calculateOccurrenceAt(index: number): Date {
switch (this.props.unit) {
case "week":
@ -187,9 +188,7 @@ export class RecurrenceSchedule extends ValueObject<RecurrenceScheduleProps> {
}
private static normalizeDate(value: Date): Date {
return new Date(
Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate())
);
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()));
}
private static cloneDate(value: Date): Date {

View File

@ -1,6 +1,6 @@
import { type DiscountPercentage } from "@erp/core/api";
import type { DiscountPercentage } from "@erp/core/api";
import { DomainEntity, type UniqueID } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import { Maybe, Result } from "@repo/rdx-utils";
import type { ItemAmount, ItemDescription, ItemQuantity } from "../common";
@ -14,7 +14,10 @@ export interface RecurringProformaItemCreateProps {
retentionCode?: Maybe<string>;
}
type RecurringProformaItemInternalProps = Omit<RecurringProformaItemCreateProps, "ivaCode" | "recCode" | "retentionCode"> & {
type RecurringProformaItemInternalProps = Omit<
RecurringProformaItemCreateProps,
"ivaCode" | "recCode" | "retentionCode"
> & {
ivaCode: Maybe<string>;
recCode: Maybe<string>;
retentionCode: Maybe<string>;
@ -25,12 +28,17 @@ export class RecurringProformaItem extends DomainEntity<RecurringProformaItemInt
props: RecurringProformaItemCreateProps,
id?: UniqueID
): Result<RecurringProformaItem, Error> {
return Result.ok(new RecurringProformaItem({
return Result.ok(
new RecurringProformaItem(
{
...props,
ivaCode: props.ivaCode ?? Maybe.none(),
recCode: props.recCode ?? Maybe.none(),
retentionCode: props.retentionCode ?? Maybe.none(),
}, id));
},
id
)
);
}
public static rehydrate(
@ -60,11 +68,17 @@ export class RecurringProformaItem extends DomainEntity<RecurringProformaItemInt
return this.props.itemDiscountPercentage;
}
public get ivaCode(): Maybe<string> { return this.props.ivaCode; }
public get ivaCode(): Maybe<string> {
return this.props.ivaCode;
}
public get recCode(): Maybe<string> { return this.props.recCode; }
public get recCode(): Maybe<string> {
return this.props.recCode;
}
public get retentionCode(): Maybe<string> { return this.props.retentionCode; }
public get retentionCode(): Maybe<string> {
return this.props.retentionCode;
}
public getProps(): RecurringProformaItemCreateProps {
return {

View File

@ -43,24 +43,18 @@ export class ProformaItemModel extends Model<
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;
@ -216,9 +210,11 @@ 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_name: {
type: DataTypes.STRING(100),
allowNull: true,
defaultValue: null,
},
iva_percentage_value: {
type: DataTypes.SMALLINT,
allowNull: true,
@ -244,9 +240,11 @@ 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_name: {
type: DataTypes.STRING(100),
allowNull: true,
defaultValue: null,
},
rec_percentage_value: {
type: DataTypes.SMALLINT,
allowNull: true,
@ -272,9 +270,11 @@ 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_name: {
type: DataTypes.STRING(100),
allowNull: true,
defaultValue: null,
},
retention_percentage_value: {
type: DataTypes.SMALLINT,
allowNull: true,

View File

@ -1,11 +1,11 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Tax, TaxPercentage } from "@erp/core/api";
import { 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 Proforma, ProformaItem, ProformaItemTax } from "../../../../../../domain";
import type { ProformaItemModel } from "../../../../../common";
import { SequelizeProformaV2ItemDomainMapper } from "./sequelize-proforma-v2-item-domain.mapper";
@ -27,12 +27,10 @@ const createTax = (params: {
calculationBehavior: "additive" | "subtractive";
}) =>
required(
Tax.create({
ProformaItemTax.create({
code: params.code,
name: params.name,
rate: required(TaxPercentage.create({ value: params.value })),
group: params.group,
calculationBehavior: params.calculationBehavior,
percentage: required(TaxPercentage.create({ value: params.value })),
})
);
@ -47,9 +45,7 @@ const roundTrip = (taxes: Parameters<typeof ProformaItem.create>[0]["taxes"]) =>
})
);
const mapper = new SequelizeProformaV2ItemDomainMapper();
const persistence = required(
mapper.mapToPersistence(source, { index: 0, parent, errors: [] })
);
const persistence = required(mapper.mapToPersistence(source, { index: 0, parent, errors: [] }));
const rehydrated = required(
mapper.mapToDomain(persistence as unknown as ProformaItemModel, {
index: 0,
@ -77,13 +73,8 @@ test("SequelizeProformaV2ItemDomainMapper conserva IVA completo sin fabricar nam
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
);
assert.notEqual(rehydrated.taxes.iva.unwrap().name, rehydrated.taxes.iva.unwrap().code);
});
test("SequelizeProformaV2ItemDomainMapper conserva REC completo", () => {
@ -101,8 +92,6 @@ test("SequelizeProformaV2ItemDomainMapper conserva REC completo", () => {
});
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);
});
@ -121,8 +110,6 @@ test("SequelizeProformaV2ItemDomainMapper conserva retención completa", () => {
});
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);
});

View File

@ -2,9 +2,6 @@ import {
DiscountPercentage,
type MapperParamsType,
SequelizeDomainMapper,
Tax,
type TaxCalculationBehavior,
type TaxGroup,
TaxPercentage,
} from "@erp/core/api";
import {
@ -25,6 +22,7 @@ import {
ItemQuantity,
type Proforma,
ProformaItem,
ProformaItemTax,
ProformaItemTaxes,
type ProformaItemTaxesProps,
} from "../../../../../../domain";
@ -90,8 +88,6 @@ export class SequelizeProformaV2ItemDomainMapper extends SequelizeDomainMapper<
this.mapTaxToDomain({
code: raw.iva_code,
name: raw.iva_name,
group: raw.iva_group,
calculationBehavior: raw.iva_calculation_behavior,
percentageValue: raw.iva_percentage_value,
fieldPath: `items[${index}].iva`,
}),
@ -103,8 +99,6 @@ export class SequelizeProformaV2ItemDomainMapper extends SequelizeDomainMapper<
this.mapTaxToDomain({
code: raw.rec_code,
name: raw.rec_name,
group: raw.rec_group,
calculationBehavior: raw.rec_calculation_behavior,
percentageValue: raw.rec_percentage_value,
fieldPath: `items[${index}].rec`,
}),
@ -116,8 +110,6 @@ export class SequelizeProformaV2ItemDomainMapper extends SequelizeDomainMapper<
this.mapTaxToDomain({
code: raw.retention_code,
name: raw.retention_name,
group: raw.retention_group,
calculationBehavior: raw.retention_calculation_behavior,
percentageValue: raw.retention_percentage_value,
fieldPath: `items[${index}].retention`,
}),
@ -141,17 +133,10 @@ export class SequelizeProformaV2ItemDomainMapper extends SequelizeDomainMapper<
code: string | null;
name: string | null;
percentageValue: number | null;
group: string | null;
calculationBehavior: string | null;
fieldPath: string;
}): Result<Maybe<Tax>, Error> {
}): Result<Maybe<ProformaItemTax>, Error> {
if (params.code === null || params.code.trim() === "") {
if (
params.name !== null ||
params.percentageValue !== null ||
params.group !== null ||
params.calculationBehavior !== null
) {
if (params.name !== null || params.percentageValue !== null) {
return Result.fail(
new Error(`${params.fieldPath} metadata must be absent when tax code is absent`)
);
@ -165,8 +150,10 @@ 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`));
if (!params.name) {
return Result.fail(
new Error(`${params.fieldPath}.name is required when tax code is present`)
);
}
const percentageResult = TaxPercentage.create({
@ -177,12 +164,10 @@ export class SequelizeProformaV2ItemDomainMapper extends SequelizeDomainMapper<
return Result.fail(percentageResult.error);
}
const taxResult = Tax.create({
const taxResult = ProformaItemTax.create({
code: params.code,
name: params.name,
rate: percentageResult.data,
group: params.group as TaxGroup,
calculationBehavior: params.calculationBehavior as TaxCalculationBehavior,
percentage: percentageResult.data,
});
if (taxResult.isFailure) {
@ -192,7 +177,10 @@ export class SequelizeProformaV2ItemDomainMapper extends SequelizeDomainMapper<
return Result.ok(Maybe.some(taxResult.data));
}
public mapToDomain(raw: ProformaItemModel, params?: MapperParamsType): Result<ProformaItem, Error> {
public mapToDomain(
raw: ProformaItemModel,
params?: MapperParamsType
): Result<ProformaItem, Error> {
const { errors } = params as {
index: number;
errors: ValidationErrorDetail[];
@ -282,42 +270,29 @@ export class SequelizeProformaV2ItemDomainMapper extends SequelizeDomainMapper<
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,
maybeToNullable(source.taxes.iva, (value) => value.percentage.scale) ??
TaxPercentage.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,
maybeToNullable(source.taxes.rec, (value) => value.percentage.scale) ??
TaxPercentage.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
),
retention_percentage_scale:
maybeToNullable(source.taxes.retention, (value) => value.percentage.scale) ??
Tax.DEFAULT_SCALE,
TaxPercentage.DEFAULT_SCALE,
retention_amount_value: allAmounts.retentionAmount.value,
retention_amount_scale: allAmounts.retentionAmount.scale,
taxes_amount_value: allAmounts.taxesAmount.value,

View File

@ -33,6 +33,14 @@ export class UtcDate extends ValueObject<UtcDateProps> {
return UtcDate.create(dateString);
}
static createFromDate(date: Date): Result<UtcDate, Error> {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
return UtcDate.create(`${year}-${month}-${day}`);
}
static today(): UtcDate {
const now = new Date();