diff --git a/modules/customer-invoices/src/api/application/issued-invoices/audit/issued-invoice-audit.test.ts b/modules/customer-invoices/src/api/application/issued-invoices/audit/issued-invoice-audit.test.ts index 0f9bf10d..41c26438 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/audit/issued-invoice-audit.test.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/audit/issued-invoice-audit.test.ts @@ -96,7 +96,7 @@ const createIssuedInvoiceFixture = (withOptionals = true): IssuedInvoice => { { name: "30 días", description: Maybe.some("Pago estándar"), - dueRules: [{ dueDays: "30", percentage: { value: "10000", scale: 2 } }], + dueRules: [{ dueDays: "30", percentage: { value: 10000, scale: 2 } }], }, required(UniqueID.create("01900000-0000-7000-8000-000000000010")) ) diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.test.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.test.ts index 46a8a685..09ea8034 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.test.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.test.ts @@ -149,7 +149,7 @@ test("resolvePaymentTerm convierte el DTO de porcentaje al formato de dominio", { dueDays: "30", percentage: { - value: "10000", + value: 10000, scale: 2, }, }, diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts index 199912ea..b64600e9 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts @@ -306,7 +306,7 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic dueRules: paymentTerm.dueRules.map((rule) => ({ dueDays: rule.dueDays, percentage: { - value: rule.percentage.value, + value: Number(rule.percentage.value), scale: Number(rule.percentage.scale), }, })), diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot-builder.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot-builder.ts index 9c2131f5..a1516b58 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot-builder.ts @@ -39,8 +39,8 @@ export class IssuedInvoiceFullSnapshotBuilder implements IIssuedInvoiceFullSnaps due_rules: paymentTerm.dueRules.map((rule) => ({ due_days: rule.dueDays, percentage: { - value: rule.percentage.value, - scale: rule.percentage.scale, + value: String(rule.percentage.value), + scale: String(rule.percentage.scale), }, })), })); diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot.interface.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot.interface.ts index 2ded5b3c..35091e4e 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot.interface.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot.interface.ts @@ -1,3 +1,5 @@ +import type { PercentageDTO } from "@erp/core"; + import type { IIssuedInvoiceItemFullSnapshot } from "./issued-invoice-item-full-snapshot.interface"; import type { IIssuedInvoiceRecipientFullSnapshot } from "./issued-invoice-recipient-full-snapshot.interface"; import type { IIssuedInvoiceTaxFullSnapshot } from "./issued-invoice-tax-full-snapshot-interface"; @@ -53,10 +55,7 @@ export interface IIssuedInvoiceFullSnapshot { description: string | null; due_rules: Array<{ due_days: string; - percentage: { - value: string; - scale: number; - }; + percentage: PercentageDTO; }>; } | null; tax_regime: { diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-snapshot-builder.test.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-snapshot-builder.test.ts index 8cfbf94e..e6fb6dd5 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-snapshot-builder.test.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-snapshot-builder.test.ts @@ -59,7 +59,7 @@ test("reporting usa el payment term histórico del snapshot emitido", () => { due_days: "30", percentage: { value: "10000", - scale: 2, + scale: "2", }, }, ], diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.test.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.test.ts index 35bd0952..68fae39c 100644 --- a/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.test.ts +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.test.ts @@ -79,7 +79,7 @@ const createReloadedIssuedInvoice = (id: UniqueID, companyId: UniqueID): IssuedI InvoicePaymentTerm.create({ name: "30 días", description: Maybe.none(), - dueRules: [{ dueDays: "30", percentage: { value: "10000", scale: 2 } }], + dueRules: [{ dueDays: "30", percentage: { value: 10000, scale: 2 } }], }) ); const fiscalClassification = required( diff --git a/modules/customer-invoices/src/api/domain/common/entities/invoice-payment-term.entity.test.ts b/modules/customer-invoices/src/api/domain/common/entities/invoice-payment-term.entity.test.ts new file mode 100644 index 00000000..4725f9c4 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/common/entities/invoice-payment-term.entity.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Maybe } from "@repo/rdx-utils"; + +import { InvoicePaymentTerm } from "./invoice-payment-term.entity"; + +const createPaymentTerm = (dueRules: Array<{ dueDays: string; value: number; scale: number }>) => + InvoicePaymentTerm.create({ + name: "Pago fraccionado", + description: Maybe.none(), + dueRules: dueRules.map((rule) => ({ + dueDays: rule.dueDays, + percentage: { + value: rule.value, + scale: rule.scale, + }, + })), + }); + +test("normaliza reglas de vencimiento válidas", () => { + const result = createPaymentTerm([ + { dueDays: "00", value: 5000, scale: 2 }, + { dueDays: "30", value: 5000, scale: 2 }, + ]); + + assert.equal(result.isSuccess, true); + assert.deepEqual(result.data.dueRules, [ + { dueDays: "0", percentage: { value: 5000, scale: 2 } }, + { dueDays: "30", percentage: { value: 5000, scale: 2 } }, + ]); +}); + +test("rechaza porcentajes que no cumplen las invariantes del plazo", () => { + for (const rule of [ + { dueDays: "0", value: 0, scale: 2 }, + { dueDays: "0", value: -1, scale: 2 }, + { dueDays: "0", value: 10001, scale: 2 }, + { dueDays: "0", value: 1000, scale: 1 }, + { dueDays: "0", value: 100000, scale: 3 }, + ]) { + const result = createPaymentTerm([rule]); + + assert.equal(result.isFailure, true); + } +}); + +test("rechaza porcentajes que no suman exactamente 100.00", () => { + const result = createPaymentTerm([ + { dueDays: "0", value: 4999, scale: 2 }, + { dueDays: "30", value: 5000, scale: 2 }, + ]); + + assert.equal(result.isFailure, true); +}); + +test("rechaza vencimientos duplicados tras normalizar sus días", () => { + const result = createPaymentTerm([ + { dueDays: "0", value: 5000, scale: 2 }, + { dueDays: "00", value: 5000, scale: 2 }, + ]); + + assert.equal(result.isFailure, true); +}); diff --git a/modules/customer-invoices/src/api/domain/common/entities/invoice-payment-term.entity.ts b/modules/customer-invoices/src/api/domain/common/entities/invoice-payment-term.entity.ts index d7e39d53..85ea1825 100644 --- a/modules/customer-invoices/src/api/domain/common/entities/invoice-payment-term.entity.ts +++ b/modules/customer-invoices/src/api/domain/common/entities/invoice-payment-term.entity.ts @@ -1,18 +1,23 @@ -import { DomainEntity, DomainValidationError, type UniqueID } from "@repo/rdx-ddd"; +import { + DomainEntity, + DomainValidationError, + Percentage, + type PercentageProps, + type UniqueID, +} from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; -export interface InvoicePaymentTermDueRuleSnapshot { +export type InvoicePaymentTermPercentageProps = Required; + +export interface InvoicePaymentTermDueRuleProps { dueDays: string; - percentage: { - value: string; - scale: number; - }; + percentage: InvoicePaymentTermPercentageProps; } export interface InvoicePaymentTermProps { name: string; description: Maybe; - dueRules: InvoicePaymentTermDueRuleSnapshot[]; + dueRules: InvoicePaymentTermDueRuleProps[]; } export class InvoicePaymentTerm extends DomainEntity { @@ -70,13 +75,14 @@ export class InvoicePaymentTerm extends DomainEntity { } private static normalizeDueRules( - dueRules: InvoicePaymentTermDueRuleSnapshot[] - ): Result { - const normalizedRules: InvoicePaymentTermDueRuleSnapshot[] = []; + dueRules: InvoicePaymentTermDueRuleProps[] + ): Result { + const normalizedRules: InvoicePaymentTermDueRuleProps[] = []; + const dueDaysValues = new Set(); + let totalPercentage = 0; for (const rule of dueRules) { const dueDays = rule.dueDays.trim(); - const percentageValue = rule.percentage.value.trim(); if (!/^\d+$/.test(dueDays)) { return Result.fail( @@ -88,35 +94,68 @@ export class InvoicePaymentTerm extends DomainEntity { ); } - if (!/^-?\d+$/.test(percentageValue)) { + const dueDaysValue = Number(dueDays); + if (!Number.isSafeInteger(dueDaysValue)) { return Result.fail( new DomainValidationError( "INVALID_INVOICE_PAYMENT_TERM", - "paymentTerm.dueRules.percentage.value", - "Issued invoice payment term percentage value must be an integer string" + "paymentTerm.dueRules.dueDays", + "Issued invoice payment term dueDays must be a safe integer" ) ); } - if (!Number.isInteger(rule.percentage.scale) || rule.percentage.scale < 0) { + if (dueDaysValues.has(dueDaysValue)) { return Result.fail( new DomainValidationError( "INVALID_INVOICE_PAYMENT_TERM", - "paymentTerm.dueRules.percentage.scale", - "Issued invoice payment term percentage scale must be a non-negative integer" + "paymentTerm.dueRules.dueDays", + "Issued invoice payment term dueDays must be unique" ) ); } + dueDaysValues.add(dueDaysValue); + + const percentageResult = Percentage.create(rule.percentage); + if (percentageResult.isFailure) { + return Result.fail( + new DomainValidationError( + "INVALID_INVOICE_PAYMENT_TERM", + "paymentTerm.dueRules.percentage", + percentageResult.error.message + ) + ); + } + + const percentage = percentageResult.data.toPrimitive(); + if (percentage.scale !== 2 || percentage.value <= 0) { + return Result.fail( + new DomainValidationError( + "INVALID_INVOICE_PAYMENT_TERM", + "paymentTerm.dueRules.percentage", + "Issued invoice payment term percentage must be positive with scale 2" + ) + ); + } + + totalPercentage += percentage.value; normalizedRules.push({ - dueDays, - percentage: { - value: percentageValue, - scale: rule.percentage.scale, - }, + dueDays: String(dueDaysValue), + percentage, }); } + if (totalPercentage !== 10000) { + return Result.fail( + new DomainValidationError( + "INVALID_INVOICE_PAYMENT_TERM", + "paymentTerm.dueRules", + "Issued invoice payment term percentages must sum exactly 100.00" + ) + ); + } + return Result.ok(normalizedRules); } @@ -128,7 +167,7 @@ export class InvoicePaymentTerm extends DomainEntity { return this.props.description; } - public get dueRules(): InvoicePaymentTermDueRuleSnapshot[] { + public get dueRules(): InvoicePaymentTermDueRuleProps[] { return this.props.dueRules.map((rule) => ({ dueDays: rule.dueDays, percentage: { diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-domain.mapper.test.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-domain.mapper.test.ts index a137f793..79aad4fd 100644 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-domain.mapper.test.ts +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-domain.mapper.test.ts @@ -8,7 +8,7 @@ import { SequelizeIssuedInvoiceV2DomainMapper } from "./sequelize-issued-invoice type PaymentTermRule = { dueDays: string; percentage: { - value: string; + value: number; scale: number; }; }; @@ -151,7 +151,7 @@ test("snapshot payment term hace round-trip exacto domain -> persistence -> read dueRules: [ { dueDays: "30", - percentage: { value: "10000", scale: 2 }, + percentage: { value: 10000, scale: 2 }, }, ], }, @@ -161,11 +161,11 @@ test("snapshot payment term hace round-trip exacto domain -> persistence -> read dueRules: [ { dueDays: "0", - percentage: { value: "5000", scale: 2 }, + percentage: { value: 5000, scale: 2 }, }, { dueDays: "30", - percentage: { value: "5000", scale: 2 }, + percentage: { value: 5000, scale: 2 }, }, ], }, @@ -175,15 +175,15 @@ test("snapshot payment term hace round-trip exacto domain -> persistence -> read dueRules: [ { dueDays: "0", - percentage: { value: "3333", scale: 2 }, + percentage: { value: 3333, scale: 2 }, }, { dueDays: "30", - percentage: { value: "3333", scale: 2 }, + percentage: { value: 3333, scale: 2 }, }, { dueDays: "60", - percentage: { value: "3334", scale: 2 }, + percentage: { value: 3334, scale: 2 }, }, ], }, @@ -228,7 +228,7 @@ test("rehidrata el snapshot persistido histórico sin depender del catálogo viv dueRules: [ { dueDays: "30", - percentage: { value: "10000", scale: 2 }, + percentage: { value: 10000, scale: 2 }, }, ], }) as never @@ -241,11 +241,11 @@ test("rehidrata el snapshot persistido histórico sin depender del catálogo viv dueRules: [ { dueDays: "30", - percentage: { value: "5000", scale: 2 }, + percentage: { value: 5000, scale: 2 }, }, { dueDays: "60", - percentage: { value: "5000", scale: 2 }, + percentage: { value: 5000, scale: 2 }, }, ], }; @@ -261,7 +261,7 @@ test("rehidrata el snapshot persistido histórico sin depender del catálogo viv { dueDays: "30", percentage: { - value: "10000", + value: 10000, scale: 2, }, }, diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-domain.mapper.ts index d13d4eb0..191d39cd 100644 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-domain.mapper.ts @@ -554,11 +554,11 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper< private parsePaymentTermSnapshotJson(source: string): Result< { version: 1; - dueRules: Array<{ - dueDays: string; - percentage: { - value: string; - scale: number; + dueRules: Array<{ + dueDays: string; + percentage: { + value: number; + scale: number; }; }>; }, @@ -601,7 +601,7 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper< dueRules: parsed.dueRules.map((rule) => ({ dueDays: String(rule.dueDays ?? ""), percentage: { - value: String(rule.percentage?.value ?? ""), + value: Number(rule.percentage?.value ?? Number.NaN), scale: Number(rule.percentage?.scale ?? Number.NaN), }, })),