feat: normalizar y validar reglas de vencimiento en el término de pago de facturas

This commit is contained in:
David Arranz 2026-08-26 13:41:57 +02:00
parent 88663b07c8
commit 2f15f10b8b
11 changed files with 153 additions and 51 deletions

View File

@ -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"))
)

View File

@ -149,7 +149,7 @@ test("resolvePaymentTerm convierte el DTO de porcentaje al formato de dominio",
{
dueDays: "30",
percentage: {
value: "10000",
value: 10000,
scale: 2,
},
},

View File

@ -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),
},
})),

View File

@ -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),
},
})),
}));

View File

@ -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: {

View File

@ -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",
},
},
],

View File

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

View File

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

View File

@ -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<PercentageProps>;
export interface InvoicePaymentTermDueRuleProps {
dueDays: string;
percentage: {
value: string;
scale: number;
};
percentage: InvoicePaymentTermPercentageProps;
}
export interface InvoicePaymentTermProps {
name: string;
description: Maybe<string>;
dueRules: InvoicePaymentTermDueRuleSnapshot[];
dueRules: InvoicePaymentTermDueRuleProps[];
}
export class InvoicePaymentTerm extends DomainEntity<InvoicePaymentTermProps> {
@ -70,13 +75,14 @@ export class InvoicePaymentTerm extends DomainEntity<InvoicePaymentTermProps> {
}
private static normalizeDueRules(
dueRules: InvoicePaymentTermDueRuleSnapshot[]
): Result<InvoicePaymentTermDueRuleSnapshot[], Error> {
const normalizedRules: InvoicePaymentTermDueRuleSnapshot[] = [];
dueRules: InvoicePaymentTermDueRuleProps[]
): Result<InvoicePaymentTermDueRuleProps[], Error> {
const normalizedRules: InvoicePaymentTermDueRuleProps[] = [];
const dueDaysValues = new Set<number>();
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<InvoicePaymentTermProps> {
);
}
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<InvoicePaymentTermProps> {
return this.props.description;
}
public get dueRules(): InvoicePaymentTermDueRuleSnapshot[] {
public get dueRules(): InvoicePaymentTermDueRuleProps[] {
return this.props.dueRules.map((rule) => ({
dueDays: rule.dueDays,
percentage: {

View File

@ -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,
},
},

View File

@ -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),
},
})),