feat: normalizar y validar reglas de vencimiento en el término de pago de facturas
This commit is contained in:
parent
88663b07c8
commit
2f15f10b8b
@ -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"))
|
||||
)
|
||||
|
||||
@ -149,7 +149,7 @@ test("resolvePaymentTerm convierte el DTO de porcentaje al formato de dominio",
|
||||
{
|
||||
dueDays: "30",
|
||||
percentage: {
|
||||
value: "10000",
|
||||
value: 10000,
|
||||
scale: 2,
|
||||
},
|
||||
},
|
||||
|
||||
@ -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),
|
||||
},
|
||||
})),
|
||||
|
||||
@ -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),
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
});
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@ -557,7 +557,7 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
|
||||
dueRules: Array<{
|
||||
dueDays: string;
|
||||
percentage: {
|
||||
value: string;
|
||||
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),
|
||||
},
|
||||
})),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user