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",
|
name: "30 días",
|
||||||
description: Maybe.some("Pago estándar"),
|
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"))
|
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",
|
dueDays: "30",
|
||||||
percentage: {
|
percentage: {
|
||||||
value: "10000",
|
value: 10000,
|
||||||
scale: 2,
|
scale: 2,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -306,7 +306,7 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
|
|||||||
dueRules: paymentTerm.dueRules.map((rule) => ({
|
dueRules: paymentTerm.dueRules.map((rule) => ({
|
||||||
dueDays: rule.dueDays,
|
dueDays: rule.dueDays,
|
||||||
percentage: {
|
percentage: {
|
||||||
value: rule.percentage.value,
|
value: Number(rule.percentage.value),
|
||||||
scale: Number(rule.percentage.scale),
|
scale: Number(rule.percentage.scale),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
|||||||
@ -39,8 +39,8 @@ export class IssuedInvoiceFullSnapshotBuilder implements IIssuedInvoiceFullSnaps
|
|||||||
due_rules: paymentTerm.dueRules.map((rule) => ({
|
due_rules: paymentTerm.dueRules.map((rule) => ({
|
||||||
due_days: rule.dueDays,
|
due_days: rule.dueDays,
|
||||||
percentage: {
|
percentage: {
|
||||||
value: rule.percentage.value,
|
value: String(rule.percentage.value),
|
||||||
scale: rule.percentage.scale,
|
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 { IIssuedInvoiceItemFullSnapshot } from "./issued-invoice-item-full-snapshot.interface";
|
||||||
import type { IIssuedInvoiceRecipientFullSnapshot } from "./issued-invoice-recipient-full-snapshot.interface";
|
import type { IIssuedInvoiceRecipientFullSnapshot } from "./issued-invoice-recipient-full-snapshot.interface";
|
||||||
import type { IIssuedInvoiceTaxFullSnapshot } from "./issued-invoice-tax-full-snapshot-interface";
|
import type { IIssuedInvoiceTaxFullSnapshot } from "./issued-invoice-tax-full-snapshot-interface";
|
||||||
@ -53,10 +55,7 @@ export interface IIssuedInvoiceFullSnapshot {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
due_rules: Array<{
|
due_rules: Array<{
|
||||||
due_days: string;
|
due_days: string;
|
||||||
percentage: {
|
percentage: PercentageDTO;
|
||||||
value: string;
|
|
||||||
scale: number;
|
|
||||||
};
|
|
||||||
}>;
|
}>;
|
||||||
} | null;
|
} | null;
|
||||||
tax_regime: {
|
tax_regime: {
|
||||||
|
|||||||
@ -59,7 +59,7 @@ test("reporting usa el payment term histórico del snapshot emitido", () => {
|
|||||||
due_days: "30",
|
due_days: "30",
|
||||||
percentage: {
|
percentage: {
|
||||||
value: "10000",
|
value: "10000",
|
||||||
scale: 2,
|
scale: "2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -79,7 +79,7 @@ const createReloadedIssuedInvoice = (id: UniqueID, companyId: UniqueID): IssuedI
|
|||||||
InvoicePaymentTerm.create({
|
InvoicePaymentTerm.create({
|
||||||
name: "30 días",
|
name: "30 días",
|
||||||
description: Maybe.none(),
|
description: Maybe.none(),
|
||||||
dueRules: [{ dueDays: "30", percentage: { value: "10000", scale: 2 } }],
|
dueRules: [{ dueDays: "30", percentage: { value: 10000, scale: 2 } }],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const fiscalClassification = required(
|
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";
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
export interface InvoicePaymentTermDueRuleSnapshot {
|
export type InvoicePaymentTermPercentageProps = Required<PercentageProps>;
|
||||||
|
|
||||||
|
export interface InvoicePaymentTermDueRuleProps {
|
||||||
dueDays: string;
|
dueDays: string;
|
||||||
percentage: {
|
percentage: InvoicePaymentTermPercentageProps;
|
||||||
value: string;
|
|
||||||
scale: number;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvoicePaymentTermProps {
|
export interface InvoicePaymentTermProps {
|
||||||
name: string;
|
name: string;
|
||||||
description: Maybe<string>;
|
description: Maybe<string>;
|
||||||
dueRules: InvoicePaymentTermDueRuleSnapshot[];
|
dueRules: InvoicePaymentTermDueRuleProps[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InvoicePaymentTerm extends DomainEntity<InvoicePaymentTermProps> {
|
export class InvoicePaymentTerm extends DomainEntity<InvoicePaymentTermProps> {
|
||||||
@ -70,13 +75,14 @@ export class InvoicePaymentTerm extends DomainEntity<InvoicePaymentTermProps> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static normalizeDueRules(
|
private static normalizeDueRules(
|
||||||
dueRules: InvoicePaymentTermDueRuleSnapshot[]
|
dueRules: InvoicePaymentTermDueRuleProps[]
|
||||||
): Result<InvoicePaymentTermDueRuleSnapshot[], Error> {
|
): Result<InvoicePaymentTermDueRuleProps[], Error> {
|
||||||
const normalizedRules: InvoicePaymentTermDueRuleSnapshot[] = [];
|
const normalizedRules: InvoicePaymentTermDueRuleProps[] = [];
|
||||||
|
const dueDaysValues = new Set<number>();
|
||||||
|
let totalPercentage = 0;
|
||||||
|
|
||||||
for (const rule of dueRules) {
|
for (const rule of dueRules) {
|
||||||
const dueDays = rule.dueDays.trim();
|
const dueDays = rule.dueDays.trim();
|
||||||
const percentageValue = rule.percentage.value.trim();
|
|
||||||
|
|
||||||
if (!/^\d+$/.test(dueDays)) {
|
if (!/^\d+$/.test(dueDays)) {
|
||||||
return Result.fail(
|
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(
|
return Result.fail(
|
||||||
new DomainValidationError(
|
new DomainValidationError(
|
||||||
"INVALID_INVOICE_PAYMENT_TERM",
|
"INVALID_INVOICE_PAYMENT_TERM",
|
||||||
"paymentTerm.dueRules.percentage.value",
|
"paymentTerm.dueRules.dueDays",
|
||||||
"Issued invoice payment term percentage value must be an integer string"
|
"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(
|
return Result.fail(
|
||||||
new DomainValidationError(
|
new DomainValidationError(
|
||||||
"INVALID_INVOICE_PAYMENT_TERM",
|
"INVALID_INVOICE_PAYMENT_TERM",
|
||||||
"paymentTerm.dueRules.percentage.scale",
|
"paymentTerm.dueRules.dueDays",
|
||||||
"Issued invoice payment term percentage scale must be a non-negative integer"
|
"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({
|
normalizedRules.push({
|
||||||
dueDays,
|
dueDays: String(dueDaysValue),
|
||||||
percentage: {
|
percentage,
|
||||||
value: percentageValue,
|
|
||||||
scale: rule.percentage.scale,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
return Result.ok(normalizedRules);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,7 +167,7 @@ export class InvoicePaymentTerm extends DomainEntity<InvoicePaymentTermProps> {
|
|||||||
return this.props.description;
|
return this.props.description;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get dueRules(): InvoicePaymentTermDueRuleSnapshot[] {
|
public get dueRules(): InvoicePaymentTermDueRuleProps[] {
|
||||||
return this.props.dueRules.map((rule) => ({
|
return this.props.dueRules.map((rule) => ({
|
||||||
dueDays: rule.dueDays,
|
dueDays: rule.dueDays,
|
||||||
percentage: {
|
percentage: {
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { SequelizeIssuedInvoiceV2DomainMapper } from "./sequelize-issued-invoice
|
|||||||
type PaymentTermRule = {
|
type PaymentTermRule = {
|
||||||
dueDays: string;
|
dueDays: string;
|
||||||
percentage: {
|
percentage: {
|
||||||
value: string;
|
value: number;
|
||||||
scale: number;
|
scale: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -151,7 +151,7 @@ test("snapshot payment term hace round-trip exacto domain -> persistence -> read
|
|||||||
dueRules: [
|
dueRules: [
|
||||||
{
|
{
|
||||||
dueDays: "30",
|
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: [
|
dueRules: [
|
||||||
{
|
{
|
||||||
dueDays: "0",
|
dueDays: "0",
|
||||||
percentage: { value: "5000", scale: 2 },
|
percentage: { value: 5000, scale: 2 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
dueDays: "30",
|
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: [
|
dueRules: [
|
||||||
{
|
{
|
||||||
dueDays: "0",
|
dueDays: "0",
|
||||||
percentage: { value: "3333", scale: 2 },
|
percentage: { value: 3333, scale: 2 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
dueDays: "30",
|
dueDays: "30",
|
||||||
percentage: { value: "3333", scale: 2 },
|
percentage: { value: 3333, scale: 2 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
dueDays: "60",
|
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: [
|
dueRules: [
|
||||||
{
|
{
|
||||||
dueDays: "30",
|
dueDays: "30",
|
||||||
percentage: { value: "10000", scale: 2 },
|
percentage: { value: 10000, scale: 2 },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}) as never
|
}) as never
|
||||||
@ -241,11 +241,11 @@ test("rehidrata el snapshot persistido histórico sin depender del catálogo viv
|
|||||||
dueRules: [
|
dueRules: [
|
||||||
{
|
{
|
||||||
dueDays: "30",
|
dueDays: "30",
|
||||||
percentage: { value: "5000", scale: 2 },
|
percentage: { value: 5000, scale: 2 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
dueDays: "60",
|
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",
|
dueDays: "30",
|
||||||
percentage: {
|
percentage: {
|
||||||
value: "10000",
|
value: 10000,
|
||||||
scale: 2,
|
scale: 2,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -554,11 +554,11 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
|
|||||||
private parsePaymentTermSnapshotJson(source: string): Result<
|
private parsePaymentTermSnapshotJson(source: string): Result<
|
||||||
{
|
{
|
||||||
version: 1;
|
version: 1;
|
||||||
dueRules: Array<{
|
dueRules: Array<{
|
||||||
dueDays: string;
|
dueDays: string;
|
||||||
percentage: {
|
percentage: {
|
||||||
value: string;
|
value: number;
|
||||||
scale: number;
|
scale: number;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
},
|
},
|
||||||
@ -601,7 +601,7 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
|
|||||||
dueRules: parsed.dueRules.map((rule) => ({
|
dueRules: parsed.dueRules.map((rule) => ({
|
||||||
dueDays: String(rule.dueDays ?? ""),
|
dueDays: String(rule.dueDays ?? ""),
|
||||||
percentage: {
|
percentage: {
|
||||||
value: String(rule.percentage?.value ?? ""),
|
value: Number(rule.percentage?.value ?? Number.NaN),
|
||||||
scale: Number(rule.percentage?.scale ?? Number.NaN),
|
scale: Number(rule.percentage?.scale ?? Number.NaN),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user