This commit is contained in:
David Arranz 2026-02-21 21:46:27 +01:00
parent e9824ecf80
commit cc38faed94
46 changed files with 678 additions and 1076 deletions

View File

@ -1,4 +1,4 @@
import { Tax } from "../tax";
import { Tax } from "../tax.vo";
describe("Tax Value Object", () => {
describe("Creación", () => {
@ -244,7 +244,7 @@ describe("Tax Value Object", () => {
if (result.isSuccess) {
const tax = result.value;
const json = tax.toJSON();
expect(json).toEqual({
value: 2100,
scale: 2,
@ -285,7 +285,7 @@ describe("Tax Value Object", () => {
if (result.isSuccess) {
const tax = result.value;
const props = tax.getProps();
// Intentar modificar las propiedades debe fallar
expect(() => {
(props as any).value = 3000;

View File

@ -0,0 +1,19 @@
import { Percentage, type PercentageProps } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
type DiscountPercentageProps = Pick<PercentageProps, "value">;
export class DiscountPercentage extends Percentage {
static DEFAULT_SCALE = 2;
static create({ value }: DiscountPercentageProps): Result<Percentage> {
return Percentage.create({
value,
scale: DiscountPercentage.DEFAULT_SCALE,
});
}
static zero() {
return DiscountPercentage.create({ value: 0 }).data;
}
}

View File

@ -1 +1,3 @@
export * from "./tax";
export * from "./discount-percentage.vo";
export * from "./tax.vo";
export * from "./tax-percentage.vo";

View File

@ -0,0 +1,19 @@
import { Percentage, type PercentageProps } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
type TaxPercentageProps = Pick<PercentageProps, "value">;
export class TaxPercentage extends Percentage {
static DEFAULT_SCALE = 2;
static create({ value }: TaxPercentageProps): Result<Percentage> {
return Percentage.create({
value,
scale: TaxPercentage.DEFAULT_SCALE,
});
}
static zero() {
return TaxPercentage.create({ value: 0 }).data;
}
}

View File

@ -1,20 +1,21 @@
import type { TaxCatalogProvider } from "@erp/core";
import { Percentage, ValueObject } from "@repo/rdx-ddd";
import { ValueObject } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { z } from "zod/v4";
const DEFAULT_SCALE = 2;
const DEFAULT_MIN_VALUE = 0;
const DEFAULT_MAX_VALUE = 100;
import { TaxPercentage } from "./tax-percentage.vo";
const DEFAULT_MIN_SCALE = 0;
const DEFAULT_MAX_SCALE = 4;
const DEFAULT_SCALE = TaxPercentage.DEFAULT_SCALE;
const DEFAULT_MIN_VALUE = TaxPercentage.MIN_VALUE;
const DEFAULT_MAX_VALUE = TaxPercentage.MAX_VALUE;
const DEFAULT_MIN_SCALE = TaxPercentage.MIN_SCALE;
const DEFAULT_MAX_SCALE = TaxPercentage.MAX_SCALE;
export interface TaxProps {
code: string; // iva_21
name: string; // 21% IVA
value: number; // 2100
scale: number; // 2
}
export class Tax extends ValueObject<TaxProps> {
@ -26,7 +27,7 @@ export class Tax extends ValueObject<TaxProps> {
private static CODE_REGEX = /^[a-z0-9_:-]+$/;
private _percentage!: Percentage;
private _percentage!: TaxPercentage;
protected static validate(values: TaxProps) {
const schema = z.object({
@ -55,19 +56,14 @@ export class Tax extends ValueObject<TaxProps> {
}
static create(props: TaxProps): Result<Tax> {
const { value, scale = Tax.DEFAULT_SCALE, name, code } = props;
const { value, name, code } = props;
const validationResult = Tax.validate({ value, scale, name, code });
const validationResult = Tax.validate({ value, name, code });
if (!validationResult.success) {
return Result.fail(new Error(validationResult.error.issues.map((e) => e.message).join(", ")));
}
const realValue = value / 10 ** scale;
if (realValue > Tax.MAX_VALUE) {
return Result.fail(new Error("La tasa de impuesto no puede ser mayor a 100%."));
}
return Result.ok(new Tax({ value, scale, name, code }));
return Result.ok(new Tax({ value, name, code }));
}
/**
@ -101,7 +97,6 @@ export class Tax extends ValueObject<TaxProps> {
// Delegamos en create para reusar validación y límites
return Tax.create({
value: Number(item.value),
scale: Number(item.scale) ?? Tax.DEFAULT_SCALE,
name: item.name,
code: item.code, // guardamos el code tal cual viene del catálogo
});
@ -109,9 +104,8 @@ export class Tax extends ValueObject<TaxProps> {
protected constructor(props: TaxProps) {
super(props);
this._percentage = Percentage.create({
this._percentage = TaxPercentage.create({
value: this.props.value,
scale: this.props.scale,
}).data;
}
@ -119,7 +113,7 @@ export class Tax extends ValueObject<TaxProps> {
return this.props.value;
}
get scale(): number {
return this.props.scale;
return Tax.DEFAULT_SCALE;
}
get name(): string {
return this.props.name;
@ -128,7 +122,7 @@ export class Tax extends ValueObject<TaxProps> {
return this.props.code;
}
get percentage(): Percentage {
get percentage(): TaxPercentage {
return this._percentage;
}

View File

@ -1,9 +0,0 @@
import { Collection } from "@repo/rdx-utils";
import type { Tax } from "./tax";
export class Taxes extends Collection<Tax> {
public static create<T extends Taxes>(this: new (items: Tax[]) => T, items: Tax[]): T {
return new Taxes(items);
}
}

View File

@ -1,7 +1,7 @@
export * from "./application-models";
export * from "./di";
export * from "./dtos";
//export * from "./mappers";
export * from "./mappers";
export * from "./repositories";
export * from "./services";
export * from "./snapshot-builders";

View File

@ -1,2 +1 @@
export * from "./invoice-payment-method";
export * from "./invoice-taxes";

View File

@ -1,2 +0,0 @@
export * from "./invoice-tax";
export * from "./invoice-taxes";

View File

@ -1,38 +0,0 @@
import type { Tax } from "@erp/core/api";
import { DomainEntity, type UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { InvoiceAmount } from "../../value-objects/invoice-amount.vo";
export interface InvoiceTaxProps {
tax: Tax;
taxesAmount: InvoiceAmount;
}
export class InvoiceTax extends DomainEntity<InvoiceTaxProps> {
static create(props: InvoiceTaxProps, id?: UniqueID): Result<InvoiceTax, Error> {
const invoiceTax = new InvoiceTax(props, id);
// Reglas de negocio / validaciones
// ...
// ...
return Result.ok(invoiceTax);
}
public get tax(): Tax {
return this.props.tax;
}
getProps(): InvoiceTaxProps {
return this.props;
}
toPrimitive() {
return this.getProps();
}
public getTaxAmount(taxableAmount: InvoiceAmount): InvoiceAmount {
return taxableAmount.percentage(this.tax.percentage);
}
}

View File

@ -1,40 +0,0 @@
import { Collection } from "@repo/rdx-utils";
import { InvoiceAmount, type InvoiceTaxGroup } from "../../value-objects";
export type InvoiceTaxTotal = {};
export class InvoiceTaxes extends Collection<InvoiceTaxGroup> {
constructor(items: InvoiceTaxGroup[] = [], totalItems: number | null = null) {
super(items, totalItems);
}
public getIVAAmount(): InvoiceAmount {
return this.getAll().reduce(
(total, tax) => total.add(taxableAmount.percentage(tax.percentage)),
InvoiceAmount.zero(taxableAmount.currencyCode)
);
}
public getTaxesAmountByTaxCode(taxCode: string, taxableAmount: InvoiceAmount): InvoiceAmount {
const currencyCode = taxableAmount.currencyCode;
return this.filter((itemTax) => itemTax.code === taxCode).reduce((totalAmount, itemTax) => {
return taxableAmount.percentage(itemTax.percentage).add(totalAmount);
}, InvoiceAmount.zero(currencyCode));
}
public getTaxesAmountByTaxes(taxableAmount: InvoiceAmount): InvoiceTaxTotal[] {
return this.getAll().map((taxItem) => ({
taxableAmount,
tax: taxItem,
taxesAmount: this.getTaxesAmountByTaxCode(taxItem.code, taxableAmount),
}));
}
public getCodesToString(): string {
return this.getAll()
.map((taxItem) => taxItem.code)
.join(", ");
}
}

View File

@ -1,14 +1,9 @@
export * from "./invoice-address-type.vo";
export * from "./invoice-amount.vo";
export * from "./invoice-discount-percentage.vo";
export * from "./invoice-number.vo";
export * from "./invoice-recipient";
export * from "./invoice-serie.vo";
export * from "./invoice-status.vo";
export * from "./invoice-tax-group.vo";
export * from "./invoice-tax-percentage.vo";
export * from "./item-amount.vo";
export * from "./item-description.vo";
export * from "./item-discount-percentage.vo";
export * from "./item-quantity.vo";
export * from "./item-tax-percentage.vo";

View File

@ -1,19 +0,0 @@
import { Percentage, type PercentageProps } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
type InvoiceDiscountPercentageProps = Pick<PercentageProps, "value">;
export class InvoiceDiscountPercentage extends Percentage {
static DEFAULT_SCALE = 2;
static create({ value }: InvoiceDiscountPercentageProps): Result<Percentage> {
return Percentage.create({
value,
scale: InvoiceDiscountPercentage.DEFAULT_SCALE,
});
}
static zero() {
return InvoiceDiscountPercentage.create({ value: 0 }).data;
}
}

View File

@ -1,143 +0,0 @@
import type { Tax } from "@erp/core/api";
import { ValueObject } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import type { ProformaItemTaxGroup } from "../../proformas/value-objects/proforma-item-tax-group.vo";
import { InvoiceAmount } from "./invoice-amount.vo";
export type InvoiceTaxGroupProps = {
taxableAmount: InvoiceAmount;
iva: Tax;
rec: Maybe<Tax>; // si existe
retention: Maybe<Tax>; // si existe
};
export class InvoiceTaxGroup extends ValueObject<InvoiceTaxGroupProps> {
static create(props: InvoiceTaxGroupProps) {
return Result.ok(new InvoiceTaxGroup(props));
}
/**
* Crea un grupo vacío a partir de un ItemTaxGroup (línea)
*/
static fromItem(lineTaxes: ProformaItemTaxGroup, taxableAmount: InvoiceAmount): InvoiceTaxGroup {
const iva = lineTaxes.iva.unwrap(); // iva siempre obligatorio
const rec = lineTaxes.rec;
const retention = lineTaxes.retention;
return new InvoiceTaxGroup({
iva,
rec,
retention,
taxableAmount,
});
}
calculateAmounts() {
const taxableAmount = this.props.taxableAmount;
const ivaAmount = taxableAmount.percentage(this.props.iva.percentage);
const recAmount = this.props.rec.match(
(rec) => taxableAmount.percentage(rec.percentage),
() => InvoiceAmount.zero(taxableAmount.currencyCode)
);
const retentionAmount = this.props.retention.match(
(retention) => taxableAmount.percentage(retention.percentage).multiply(-1),
() => InvoiceAmount.zero(taxableAmount.currencyCode)
);
const totalAmount = ivaAmount.add(recAmount).add(retentionAmount);
return { ivaAmount, recAmount, retentionAmount, totalAmount };
}
get iva(): Tax {
return this.props.iva;
}
get rec(): Maybe<Tax> {
return this.props.rec;
}
get retention(): Maybe<Tax> {
return this.props.retention;
}
get taxableAmount(): InvoiceAmount {
return this.props.taxableAmount;
}
/**
* Clave única del grupo: iva|rec|ret
*/
public getKey(): string {
const iva = this.props.iva.code;
const rec = this.props.rec.match(
(t) => t.code,
() => ""
);
const retention = this.props.retention.match(
(t) => t.code,
() => ""
);
return `${iva}|${rec}|${retention}`;
}
/**
* Suma una base imponible a este grupo.
*
* Devuelve un nuevo InvoiceTaxGroup (inmutabilidad).
*/
public addTaxable(amount: InvoiceAmount): InvoiceTaxGroup {
return new InvoiceTaxGroup({
...this.props,
taxableAmount: this.props.taxableAmount.add(amount),
});
}
/**
* Devuelve únicamente los códigos existentes: ["iva_21", "rec_5_2"]
*/
public getCodesArray(): string[] {
const codes: string[] = [];
// IVA
codes.push(this.props.iva.code);
this.props.rec.match(
(t) => codes.push(t.code),
() => {
//
}
);
this.props.retention.match(
(t) => codes.push(t.code),
() => {
//
}
);
return codes;
}
/**
* Devuelve una cadena tipo: "iva_21, rec_5_2"
*/
public getCodesToString(): string {
return this.getCodesArray().join(", ");
}
getProps() {
return this.props;
}
toPrimitive() {
return this.getProps();
}
}

View File

@ -1,19 +0,0 @@
import { Percentage, type PercentageProps } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
type InvoiceTaxPercentageProps = Pick<PercentageProps, "value">;
export class InvoiceTaxPercentage extends Percentage {
static DEFAULT_SCALE = 2;
static create({ value }: InvoiceTaxPercentageProps): Result<Percentage> {
return Percentage.create({
value,
scale: InvoiceTaxPercentage.DEFAULT_SCALE,
});
}
static zero() {
return InvoiceTaxPercentage.create({ value: 0 }).data;
}
}

View File

@ -1,19 +0,0 @@
import { Percentage, type PercentageProps } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
type ItemTaxPercentageProps = Pick<PercentageProps, "value">;
export class ItemTaxPercentage extends Percentage {
static DEFAULT_SCALE = 2;
static create({ value }: ItemTaxPercentageProps): Result<Percentage> {
return Percentage.create({
value,
scale: ItemTaxPercentage.DEFAULT_SCALE,
});
}
static zero() {
return ItemTaxPercentage.create({ value: 0 }).data;
}
}

View File

@ -1,3 +1,4 @@
import type { DiscountPercentage } from "@erp/core/api";
import {
AggregateRoot,
type CurrencyCode,
@ -50,7 +51,7 @@ export type IssuedInvoiceProps = {
subtotalAmount: InvoiceAmount;
itemsDiscountAmount: InvoiceAmount;
globalDiscountPercentage: Percentage;
globalDiscountPercentage: DiscountPercentage;
globalDiscountAmount: InvoiceAmount;
totalDiscountAmount: InvoiceAmount;

View File

@ -1,18 +1,8 @@
import {
type CurrencyCode,
DomainEntity,
type LanguageCode,
type Percentage,
type UniqueID,
} from "@repo/rdx-ddd";
import type { DiscountPercentage, TaxPercentage } from "@erp/core/api";
import { type CurrencyCode, DomainEntity, type LanguageCode, type UniqueID } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import type {
ItemAmount,
ItemDescription,
ItemDiscountPercentage,
ItemQuantity,
} from "../../../common";
import type { ItemAmount, ItemDescription, ItemQuantity } from "../../../common";
/**
*
@ -32,10 +22,10 @@ export type IssuedInvoiceItemProps = {
subtotalAmount: ItemAmount;
itemDiscountPercentage: Maybe<ItemDiscountPercentage>;
itemDiscountPercentage: Maybe<DiscountPercentage>;
itemDiscountAmount: ItemAmount;
globalDiscountPercentage: Maybe<ItemDiscountPercentage>;
globalDiscountPercentage: Maybe<DiscountPercentage>;
globalDiscountAmount: ItemAmount;
totalDiscountAmount: ItemAmount;
@ -43,15 +33,15 @@ export type IssuedInvoiceItemProps = {
taxableAmount: ItemAmount;
ivaCode: Maybe<string>;
ivaPercentage: Maybe<ItemDiscountPercentage>;
ivaPercentage: Maybe<DiscountPercentage>;
ivaAmount: ItemAmount;
recCode: Maybe<string>;
recPercentage: Maybe<ItemDiscountPercentage>;
recPercentage: Maybe<DiscountPercentage>;
recAmount: ItemAmount;
retentionCode: Maybe<string>;
retentionPercentage: Maybe<ItemDiscountPercentage>;
retentionPercentage: Maybe<DiscountPercentage>;
retentionAmount: ItemAmount;
taxesAmount: ItemAmount;
@ -136,7 +126,7 @@ export class IssuedInvoiceItem extends DomainEntity<IssuedInvoiceItemProps> {
public get ivaCode(): Maybe<string> {
return this.props.ivaCode;
}
public get ivaPercentage(): Maybe<Percentage> {
public get ivaPercentage(): Maybe<TaxPercentage> {
return this.props.ivaPercentage;
}
public get ivaAmount(): ItemAmount {
@ -146,7 +136,7 @@ export class IssuedInvoiceItem extends DomainEntity<IssuedInvoiceItemProps> {
public get recCode(): Maybe<string> {
return this.props.recCode;
}
public get recPercentage(): Maybe<Percentage> {
public get recPercentage(): Maybe<TaxPercentage> {
return this.props.recPercentage;
}
public get recAmount(): ItemAmount {
@ -156,7 +146,7 @@ export class IssuedInvoiceItem extends DomainEntity<IssuedInvoiceItemProps> {
public get retentionCode(): Maybe<string> {
return this.props.retentionCode;
}
public get retentionPercentage(): Maybe<Percentage> {
public get retentionPercentage(): Maybe<TaxPercentage> {
return this.props.retentionPercentage;
}
public get retentionAmount(): ItemAmount {

View File

@ -1,3 +1,4 @@
import type { TaxPercentage } from "@erp/core/api";
import { DomainEntity, type Percentage, type UniqueID } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
@ -12,11 +13,11 @@ export type IssuedInvoiceTaxProps = {
recCode: Maybe<string>;
recPercentage: Maybe<Percentage>;
recAmount: Maybe<InvoiceAmount>;
recAmount: InvoiceAmount;
retentionCode: Maybe<string>;
retentionPercentage: Maybe<Percentage>;
retentionAmount: Maybe<InvoiceAmount>;
retentionAmount: InvoiceAmount;
taxesAmount: InvoiceAmount;
};
@ -36,7 +37,7 @@ export class IssuedInvoiceTax extends DomainEntity<IssuedInvoiceTaxProps> {
public get ivaCode(): string {
return this.props.ivaCode;
}
public get ivaPercentage(): Percentage {
public get ivaPercentage(): TaxPercentage {
return this.props.ivaPercentage;
}
public get ivaAmount(): InvoiceAmount {
@ -46,20 +47,20 @@ export class IssuedInvoiceTax extends DomainEntity<IssuedInvoiceTaxProps> {
public get recCode(): Maybe<string> {
return this.props.recCode;
}
public get recPercentage(): Maybe<Percentage> {
public get recPercentage(): Maybe<TaxPercentage> {
return this.props.recPercentage;
}
public get recAmount(): Maybe<InvoiceAmount> {
public get recAmount(): InvoiceAmount {
return this.props.recAmount;
}
public get retentionCode(): Maybe<string> {
return this.props.retentionCode;
}
public get retentionPercentage(): Maybe<Percentage> {
public get retentionPercentage(): Maybe<TaxPercentage> {
return this.props.retentionPercentage;
}
public get retentionAmount(): Maybe<InvoiceAmount> {
public get retentionAmount(): InvoiceAmount {
return this.props.retentionAmount;
}

View File

@ -1,3 +1,4 @@
import type { DiscountPercentage } from "@erp/core/api";
import {
AggregateRoot,
type CurrencyCode,
@ -8,7 +9,7 @@ import {
type UniqueID,
type UtcDate,
} from "@repo/rdx-ddd";
import { Collection, type Maybe, Result } from "@repo/rdx-utils";
import { type Collection, type Maybe, Result } from "@repo/rdx-utils";
import type { InvoicePaymentMethod } from "../../common/entities";
import {
@ -17,11 +18,10 @@ import {
type InvoiceRecipient,
type InvoiceSerie,
type InvoiceStatus,
InvoiceTaxGroup,
type ItemAmount,
} from "../../common/value-objects";
import type { ProformaTaxes } from "../entities";
import { ProformaItems } from "../entities/proforma-items";
import { type IProformaTaxTotals, ProformaTaxCalculator } from "../services";
export type ProformaProps = {
companyId: UniqueID;
@ -46,27 +46,51 @@ export type ProformaProps = {
paymentMethod: Maybe<InvoicePaymentMethod>;
items: ProformaItems;
globalDiscountPercentage: Percentage;
globalDiscountPercentage: DiscountPercentage;
};
export interface IProforma extends AggregateRoot<ProformaProps> {
getTaxes: ProformaTaxes;
export interface IProformaTotals {
subtotalAmount: InvoiceAmount;
getSubtotalAmount: InvoiceAmount;
itemDiscountAmount: InvoiceAmount;
globalDiscountAmount: InvoiceAmount;
totalDiscountAmount: InvoiceAmount;
getItemsDiscountAmount: InvoiceAmount;
getGlobalDiscountPercentage: Percentage;
getGlobalDiscountAmount: InvoiceAmount;
getTotalDiscountAmount: InvoiceAmount;
taxableAmount: InvoiceAmount;
getTaxableAmount: InvoiceAmount;
ivaAmount: InvoiceAmount;
recAmount: InvoiceAmount;
retentionAmount: InvoiceAmount;
getIvaAmount: InvoiceAmount;
getRecAmount: InvoiceAmount;
getRetentionAmount: InvoiceAmount;
taxesAmount: InvoiceAmount;
totalAmount: InvoiceAmount;
}
getTaxesAmount: InvoiceAmount;
getTotalAmount: InvoiceAmount;
export interface IProforma {
companyId: UniqueID;
status: InvoiceStatus;
series: Maybe<InvoiceSerie>;
invoiceNumber: InvoiceNumber;
invoiceDate: UtcDate;
operationDate: Maybe<UtcDate>;
customerId: UniqueID;
recipient: Maybe<InvoiceRecipient>;
reference: Maybe<string>;
description: Maybe<string>;
notes: Maybe<TextValue>;
languageCode: LanguageCode;
currencyCode: CurrencyCode;
paymentMethod: Maybe<InvoicePaymentMethod>;
items: ProformaItems;
taxes(): Collection<IProformaTaxTotals>;
totals(): IProformaTotals;
}
export type ProformaPatchProps = Partial<Omit<ProformaProps, "companyId" | "items">> & {
@ -142,10 +166,6 @@ export class Proforma extends AggregateRoot<ProformaProps> implements IProforma
return this.props.status;
}
canTransitionTo(nextStatus: string): boolean {
return this.props.status.canTransitionTo(nextStatus);
}
public get series(): Maybe<InvoiceSerie> {
return this.props.series;
}
@ -207,6 +227,41 @@ export class Proforma extends AggregateRoot<ProformaProps> implements IProforma
return this.paymentMethod.isSome();
}
// Cálculos
/**
* @summary Calcula todos los totales de factura a partir de los totales de las líneas.
* La cabecera NO recalcula lógica de porcentaje toda la lógica está en Item/Items.
*/
public totals(): IProformaTotals {
const itemsTotals = this.items.totals();
return {
subtotalAmount: this.toInvoiceAmount(itemsTotals.subtotalAmount),
itemDiscountAmount: this.toInvoiceAmount(itemsTotals.itemDiscountAmount),
globalDiscountAmount: this.toInvoiceAmount(itemsTotals.globalDiscountAmount),
totalDiscountAmount: this.toInvoiceAmount(itemsTotals.totalDiscountAmount),
taxableAmount: this.toInvoiceAmount(itemsTotals.taxableAmount),
ivaAmount: this.toInvoiceAmount(itemsTotals.ivaAmount),
recAmount: this.toInvoiceAmount(itemsTotals.recAmount),
retentionAmount: this.toInvoiceAmount(itemsTotals.retentionAmount),
taxesAmount: this.toInvoiceAmount(itemsTotals.taxesAmount),
totalAmount: this.toInvoiceAmount(itemsTotals.totalAmount),
} as const;
}
public taxes(): Collection<IProformaTaxTotals> {
return new ProformaTaxCalculator(this.items).calculate();
}
public getProps(): ProformaProps {
return this.props;
}
// Helpers
/**
@ -218,101 +273,4 @@ export class Proforma extends AggregateRoot<ProformaProps> implements IProforma
currency_code: this.currencyCode.code,
}).data;
}
// Cálculos
/**
* @summary Calcula todos los totales de factura a partir de los totales de las líneas.
* La cabecera NO recalcula lógica de porcentaje toda la lógica está en Item/Items.
*/
public calculateAllAmounts() {
const itemsTotals = this.items.calculateAllAmounts();
const subtotalAmount = this.toInvoiceAmount(itemsTotals.subtotalAmount);
const itemDiscountAmount = this.toInvoiceAmount(itemsTotals.itemDiscountAmount);
const globalDiscountAmount = this.toInvoiceAmount(itemsTotals.globalDiscountAmount);
const totalDiscountAmount = this.toInvoiceAmount(itemsTotals.totalDiscountAmount);
const taxableAmount = this.toInvoiceAmount(itemsTotals.taxableAmount);
const taxesAmount = this.toInvoiceAmount(itemsTotals.taxesAmount);
const totalAmount = this.toInvoiceAmount(itemsTotals.totalAmount);
const taxGroups = this.getTaxes();
return {
subtotalAmount,
itemDiscountAmount,
globalDiscountAmount,
totalDiscountAmount,
taxableAmount,
taxesAmount,
totalAmount,
taxGroups,
} as const;
}
// Métodos públicos
public getProps(): ProformaProps {
return this.props;
}
public getSubtotalAmount(): InvoiceAmount {
return this.calculateAllAmounts().subtotalAmount;
}
public getItemDiscountAmount(): InvoiceAmount {
return this.calculateAllAmounts().itemDiscountAmount;
}
public getGlobalDiscountAmount(): InvoiceAmount {
return this.calculateAllAmounts().globalDiscountAmount;
}
public getTotalDiscountAmount(): InvoiceAmount {
return this.calculateAllAmounts().totalDiscountAmount;
}
public getTaxableAmount(): InvoiceAmount {
return this.calculateAllAmounts().taxableAmount;
}
public getTaxesAmount(): InvoiceAmount {
return this.calculateAllAmounts().taxesAmount;
}
public getTotalAmount(): InvoiceAmount {
return this.calculateAllAmounts().totalAmount;
}
/**
* @summary Agrupa impuestos a nivel factura usando el trío (iva|rec|ret),
* construyendo InvoiceTaxGroup desde los datos de los ítems.
*/
public getTaxes(): Collection<InvoiceTaxGroup> {
const map = this.items.groupTaxesByCode();
const groups: InvoiceTaxGroup[] = [];
for (const [, entry] of map.entries()) {
const { taxes, taxable } = entry;
const iva = taxes.iva.unwrap(); // IVA siempre obligatorio
const rec = taxes.rec; // Maybe<Tax>
const retention = taxes.retention; // Maybe<Tax>
const taxableAmount = this.toInvoiceAmount(taxable);
const group = InvoiceTaxGroup.create({
iva,
rec,
retention,
taxableAmount,
}).data;
groups.push(group);
}
return new Collection(groups);
}
}

View File

@ -1,2 +1 @@
export * from "./proforma-items";
export * from "./proforma-taxes";

View File

@ -1,14 +1,9 @@
import type { Tax } from "@erp/core/api";
import { DiscountPercentage, type Tax, type TaxPercentage } from "@erp/core/api";
import { type CurrencyCode, DomainEntity, type LanguageCode, type UniqueID } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import {
ItemAmount,
type ItemDescription,
ItemDiscountPercentage,
ItemQuantity,
} from "../../../common";
import type { ProformaItemTaxGroup } from "../../value-objects/proforma-item-tax-group.vo";
import { ItemAmount, type ItemDescription, type ItemQuantity } from "../../../common";
import type { ProformaItemTaxes } from "../../value-objects/proforma-item-taxes.vo";
/**
*
@ -33,60 +28,63 @@ export type ProformaItemProps = {
quantity: Maybe<ItemQuantity>; // Cantidad de unidades
unitAmount: Maybe<ItemAmount>; // Precio unitario en la moneda de la factura
itemDiscountPercentage: Maybe<ItemDiscountPercentage>; // % descuento de línea
itemDiscountPercentage: Maybe<DiscountPercentage>; // % descuento de línea
taxes: ProformaItemTaxGroup;
taxes: ProformaItemTaxes;
// Estos campos vienen de la cabecera,
// pero se necesitan para cálculos y representaciones de la línea.
globalDiscountPercentage: Maybe<ItemDiscountPercentage>; // % descuento de la cabecera
globalDiscountPercentage: DiscountPercentage; // % descuento de la cabecera
languageCode: LanguageCode; // Para formateos específicos de idioma
currencyCode: CurrencyCode; // Para cálculos y formateos de moneda
};
export interface IProformaItem extends ProformaItemProps {
export interface IProformaItemTotals {
subtotalAmount: ItemAmount;
itemDiscountAmount: ItemAmount;
globalDiscountAmount: ItemAmount;
totalDiscountAmount: ItemAmount;
taxableAmount: ItemAmount;
ivaAmount: ItemAmount;
recAmount: ItemAmount;
retentionAmount: ItemAmount;
taxesAmount: ItemAmount;
totalAmount: ItemAmount;
}
export interface IProformaItem {
description: Maybe<ItemDescription>;
isValued: boolean; // Indica si el item tiene cantidad o precio (o ambos) para ser considerado "valorizado"
languageCode: LanguageCode;
currencyCode: CurrencyCode;
quantity: Maybe<ItemQuantity>;
unitAmount: Maybe<ItemAmount>;
getSubtotalAmount: Maybe<ItemAmount>;
taxes: ProformaItemTaxes;
itemDiscountPercentage: Maybe<ItemDiscountPercentage>; // % descuento de línea
getItemDiscountAmount: Maybe<ItemAmount>;
itemDiscountPercentage: Maybe<DiscountPercentage>; // Descuento en línea
globalDiscountPercentage: DiscountPercentage; // Descuento en cabecera
globalDiscountPercentage: Maybe<ItemDiscountPercentage>; // % descuento de la cabecera
getGlobalDiscountAmount: Maybe<ItemAmount>;
ivaCode(): Maybe<string>;
ivaPercentage(): Maybe<TaxPercentage>;
getTotalDiscountAmount: Maybe<ItemAmount>;
recCode(): Maybe<string>;
recPercentage(): Maybe<TaxPercentage>;
getTaxableAmount: Maybe<ItemAmount>;
retentionCode(): Maybe<string>;
retentionPercentage(): Maybe<TaxPercentage>;
getIva: Maybe<Tax>;
getIvaCode: Maybe<string>;
getIvaPercentage: Maybe<ItemDiscountPercentage>;
getIvaAmount: ItemAmount;
totals(): IProformaItemTotals;
getRec: Maybe<Tax>;
getRecCode: Maybe<string>;
getRecPercentage: Maybe<ItemDiscountPercentage>;
getRecAmount: ItemAmount;
getRetention: Maybe<Tax>;
getRetentionCode: Maybe<string>;
getRetentionPercentage: Maybe<ItemDiscountPercentage>;
getRetentionAmount: ItemAmount;
getTaxesAmount: ItemAmount;
getTotalAmount: ItemAmount;
languageCode: LanguageCode;
currencyCode: CurrencyCode;
isValued(): boolean; // Indica si el item tiene cantidad o precio (o ambos) para ser considerado "valorizado"
}
export class ProformaItem extends DomainEntity<ProformaItemProps> {
export class ProformaItem extends DomainEntity<ProformaItemProps> implements IProformaItem {
public static create(props: ProformaItemProps, id?: UniqueID): Result<ProformaItem, Error> {
const item = new ProformaItem(props, id);
@ -101,16 +99,18 @@ export class ProformaItem extends DomainEntity<ProformaItemProps> {
super(props, id);
}
// Getters
get isValued(): boolean {
return this.props.quantity.isSome() || this.props.unitAmount.isSome();
}
get description() {
return this.props.description;
}
get languageCode() {
return this.props.languageCode;
}
get currencyCode() {
return this.props.currencyCode;
}
get quantity() {
return this.props.quantity;
}
@ -131,14 +131,6 @@ export class ProformaItem extends DomainEntity<ProformaItemProps> {
return this.props.taxes;
}
get languageCode() {
return this.props.languageCode;
}
get currencyCode() {
return this.props.currencyCode;
}
getProps(): ProformaItemProps {
return this.props;
}
@ -147,93 +139,87 @@ export class ProformaItem extends DomainEntity<ProformaItemProps> {
return this.getProps();
}
// Getters específicos para cálculos y representaciones
// Cálculos y representaciones
// Todos a 4 decimales
public getSubtotalAmount(): ItemAmount {
return this.calculateAllAmounts().subtotalAmount;
public isValued(): boolean {
return this.props.quantity.isSome() || this.props.unitAmount.isSome();
}
public getItemDiscountAmount(): ItemAmount {
return this.calculateAllAmounts().itemDiscountAmount;
public subtotalAmount(): ItemAmount {
if (!this.isValued()) {
return ItemAmount.zero(this.currencyCode.code);
}
const quantity = this.quantity.unwrap();
const unitAmount = this.unitAmount.unwrap();
return unitAmount.multiply(quantity);
}
public getGlobalDiscountAmount(): ItemAmount {
return this.calculateAllAmounts().globalDiscountAmount;
}
public getTotalDiscountAmount(): ItemAmount {
return this.calculateAllAmounts().totalDiscountAmount;
}
public getTaxableAmount(): ItemAmount {
return this.calculateAllAmounts().taxableAmount;
}
public getIva(): Maybe<Tax> {
public iva(): Maybe<Tax> {
return this.taxes.iva;
}
public getIvaCode(): Maybe<string> {
public ivaCode(): Maybe<string> {
return this.taxes.iva.map((tax) => tax.code);
}
public getIvaPercentage(): Maybe<ItemDiscountPercentage> {
public ivaPercentage(): Maybe<DiscountPercentage> {
return this.taxes.iva.map((tax) => tax.percentage);
}
public getIvaAmount(): ItemAmount {
return this.calculateAllAmounts().ivaAmount;
}
public getRec(): Maybe<Tax> {
public rec(): Maybe<Tax> {
return this.taxes.rec;
}
public getRecCode(): Maybe<string> {
public recCode(): Maybe<string> {
return this.taxes.rec.map((tax) => tax.code);
}
public getRecPercentage(): Maybe<ItemDiscountPercentage> {
public recPercentage(): Maybe<DiscountPercentage> {
return this.taxes.rec.map((tax) => tax.percentage);
}
public getIndividualTaxAmounts() {
const { ivaAmount, recAmount, retentionAmount } = this.calculateAllAmounts();
return { ivaAmount, recAmount, retentionAmount };
public retention(): Maybe<Tax> {
return this.taxes.retention;
}
public getTaxesAmount(): ItemAmount {
return this.calculateAllAmounts().taxesAmount;
public retentionCode(): Maybe<string> {
return this.taxes.retention.map((tax) => tax.code);
}
public getTotalAmount(): ItemAmount {
return this.calculateAllAmounts().totalAmount;
public retentionPercentage(): Maybe<DiscountPercentage> {
return this.taxes.retention.map((tax) => tax.percentage);
}
// Ayudantes
// Cálculos / Ayudantes
/**
* @summary Helper puro para calcular el subtotal.
*/
private _calculateSubtotalAmount(): ItemAmount {
const qty = this.quantity.match(
(quantity) => quantity,
() => ItemQuantity.zero()
);
const unit = this.unitAmount.match(
(unitAmount) => unitAmount,
() => ItemAmount.zero(this.currencyCode.code)
);
return unit.multiply(qty);
if (!this.isValued()) {
return ItemAmount.zero(this.currencyCode.code);
}
const quantity = this.quantity.unwrap();
const unitAmount = this.unitAmount.unwrap();
return unitAmount.multiply(quantity);
}
/**
* @summary Helper puro para calcular el descuento de línea.
*/
private _calculateItemDiscountAmount(subtotal: ItemAmount): ItemAmount {
if (!this.isValued() || this.props.itemDiscountPercentage.isNone()) {
return ItemAmount.zero(this.currencyCode.code);
}
const discountPercentage = this.props.itemDiscountPercentage.match(
(discount) => discount,
() => ItemDiscountPercentage.zero()
() => DiscountPercentage.zero()
);
return subtotal.percentage(discountPercentage);
@ -246,13 +232,13 @@ export class ProformaItem extends DomainEntity<ProformaItemProps> {
subtotalAmount: ItemAmount,
discountAmount: ItemAmount
): ItemAmount {
if (!this.isValued()) {
return ItemAmount.zero(this.currencyCode.code);
}
const amountAfterLineDiscount = subtotalAmount.subtract(discountAmount);
const globalDiscount = this.props.globalDiscountPercentage.match(
(discount) => discount,
() => ItemDiscountPercentage.zero()
);
const globalDiscount = this.props.globalDiscountPercentage;
return amountAfterLineDiscount.percentage(globalDiscount);
}
@ -267,8 +253,6 @@ export class ProformaItem extends DomainEntity<ProformaItemProps> {
return itemDiscountAmount.add(globalDiscountAmount);
}
// Cálculos
/**
* @summary Cálculo centralizado de todos los valores intermedios.
* @returns Devuelve un objeto inmutable con todos los valores necesarios:
@ -284,7 +268,7 @@ export class ProformaItem extends DomainEntity<ProformaItemProps> {
* - totalAmount
*
*/
public calculateAllAmounts() {
public totals(): IProformaItemTotals {
const subtotalAmount = this._calculateSubtotalAmount();
const itemDiscountAmount = this._calculateItemDiscountAmount(subtotalAmount);
@ -320,6 +304,6 @@ export class ProformaItem extends DomainEntity<ProformaItemProps> {
taxesAmount,
totalAmount,
} as const;
};
}
}

View File

@ -1,63 +1,52 @@
import type { CurrencyCode, LanguageCode, Percentage } from "@repo/rdx-ddd";
import type { DiscountPercentage } from "@erp/core/api";
import type { CurrencyCode, LanguageCode } from "@repo/rdx-ddd";
import { Collection } from "@repo/rdx-utils";
import { ItemAmount, ItemDiscountPercentage, type ItemTaxGroup } from "../../../common";
import { ItemAmount } from "../../../common";
import type { ProformaItem } from "./proforma-item.entity";
import type { IProformaItem, IProformaItemTotals, ProformaItem } from "./proforma-item.entity";
export type ProformaItemsProps = {
items?: ProformaItem[];
languageCode: LanguageCode;
currencyCode: CurrencyCode;
globalDiscountPercentage: Percentage;
// Estos campos vienen de la cabecera,
// pero se necesitan para cálculos y representaciones de la línea.
globalDiscountPercentage: DiscountPercentage; // % descuento de la cabecera
languageCode: LanguageCode; // Para formateos específicos de idioma
currencyCode: CurrencyCode; // Para cálculos y formateos de moneda
};
export class ProformaItems extends Collection<ProformaItem> {
private languageCode!: LanguageCode;
private currencyCode!: CurrencyCode;
private globalDiscountPercentage!: Percentage;
export interface IProformaItems extends Collection<IProformaItem> {
valued(): IProformaItem[]; // Devuelve solo las líneas valoradas.
totals(): IProformaItemTotals;
globalDiscountPercentage: DiscountPercentage; // % descuento de la cabecera
languageCode: LanguageCode; // Para formateos específicos de idioma
currencyCode: CurrencyCode; // Para cálculos y formateos de moneda
}
export class ProformaItems extends Collection<ProformaItem> implements IProformaItems {
public readonly languageCode!: LanguageCode;
public readonly currencyCode!: CurrencyCode;
public readonly globalDiscountPercentage!: DiscountPercentage;
constructor(props: ProformaItemsProps) {
super(props.items ?? []);
this.languageCode = props.languageCode;
this.currencyCode = props.currencyCode;
this.globalDiscountPercentage = props.globalDiscountPercentage;
this.ensureSameCurrencyAndLanguage(this.items);
}
public static create(props: ProformaItemsProps): ProformaItems {
return new ProformaItems(props);
}
// Helpers
private _sumAmounts(selector: (item: ProformaItem) => ItemAmount): ItemAmount {
return this.getAll().reduce(
(acc, item) => acc.add(selector(item)),
ItemAmount.zero(this.currencyCode.code)
);
public valued(): IProformaItem[] {
return this.filter((item) => item.isValued());
}
/**
* @summary Helper puro para sumar impuestos individuales por tipo.
*/
private _calculateIndividualTaxes() {
let iva = ItemAmount.zero(this.currencyCode.code);
let rec = ItemAmount.zero(this.currencyCode.code);
let retention = ItemAmount.zero(this.currencyCode.code);
for (const item of this.getAll()) {
const { ivaAmount, recAmount, retentionAmount } = item.getIndividualTaxAmounts();
iva = iva.add(ivaAmount);
rec = rec.add(recAmount);
retention = retention.add(retentionAmount);
}
return { iva, rec, retention };
}
//
/**
* @summary Añade un nuevo ítem a la colección.
* @param item - El ítem de factura a añadir.
@ -67,18 +56,13 @@ export class ProformaItems extends Collection<ProformaItem> {
* los de la colección. Si no coinciden, el método devuelve `false` sin modificar
* la colección.
*/
add(item: ProformaItem): boolean {
public add(item: ProformaItem): boolean {
// Antes de añadir un nuevo item, debo comprobar que el item a añadir
// tiene el mismo "currencyCode" y "languageCode" que la colección de items.
const same =
this.languageCode.equals(item.languageCode) &&
this.currencyCode.equals(item.currencyCode) &&
this.globalDiscountPercentage.equals(
item.globalDiscountPercentage.match(
(v) => v,
() => ItemDiscountPercentage.zero()
)
);
this.globalDiscountPercentage.equals(item.globalDiscountPercentage);
if (!same) return false;
@ -92,7 +76,7 @@ export class ProformaItems extends Collection<ProformaItem> {
* @remarks
* Delega en los ítems individuales (DDD correcto) pero evita múltiples recorridos.
*/
public calculateAllAmounts() {
public totals(): IProformaItemTotals {
let subtotalAmount = ItemAmount.zero(this.currencyCode.code);
let itemDiscountAmount = ItemAmount.zero(this.currencyCode.code);
@ -109,7 +93,7 @@ export class ProformaItems extends Collection<ProformaItem> {
let totalAmount = ItemAmount.zero(this.currencyCode.code);
for (const item of this.getAll()) {
const amounts = item.calculateAllAmounts();
const amounts = item.totals();
// Subtotales
subtotalAmount = subtotalAmount.add(amounts.subtotalAmount);
@ -152,114 +136,11 @@ export class ProformaItems extends Collection<ProformaItem> {
} as const;
}
public getSubtotalAmount(): ItemAmount {
return this.calculateAllAmounts().subtotalAmount;
}
public getItemDiscountAmount(): ItemAmount {
return this.calculateAllAmounts().itemDiscountAmount;
}
public getGlobalDiscountAmount(): ItemAmount {
return this.calculateAllAmounts().globalDiscountAmount;
}
public getTotalDiscountAmount(): ItemAmount {
return this.calculateAllAmounts().totalDiscountAmount;
}
public getTaxableAmount(): ItemAmount {
return this.calculateAllAmounts().taxableAmount;
}
public getTaxesAmount(): ItemAmount {
return this.calculateAllAmounts().taxesAmount;
}
public getTotalAmount(): ItemAmount {
return this.calculateAllAmounts().totalAmount;
}
/**
* @summary Recalcula totales agrupando por el trío iva, rec y retención.
*/
public groupTaxesByCode() {
const map = new Map<
string,
{
taxes: ItemTaxGroup;
taxable: ItemAmount;
ivaAmount: ItemAmount;
recAmount: ItemAmount;
retentionAmount: ItemAmount;
taxesAmount: ItemAmount;
private ensureSameCurrencyAndLanguage(items: IProformaItem[]): void {
for (const item of items) {
if (!item.currencyCode.equals(this.currencyCode)) {
throw new Error("[ProformaItems] All items must share the same currency.");
}
>();
for (const item of this.getAll()) {
const amounts = item.calculateAllAmounts();
const taxable = amounts.taxableAmount;
const { ivaAmount, recAmount, retentionAmount, taxesAmount } = amounts;
const taxes = item.taxes;
const ivaCode = taxes.iva.match(
(t) => t.code,
() => ""
);
const recCode = taxes.rec.match(
(t) => t.code,
() => ""
);
const retCode = taxes.retention.match(
(t) => t.code,
() => ""
);
// Clave del grupo: combinación IVA|REC|RET
const key = `${ivaCode}|${recCode}|${retCode}`;
const prev = map.get(key) ?? {
taxes,
taxable: ItemAmount.zero(taxable.currencyCode),
ivaAmount: ItemAmount.zero(taxable.currencyCode),
recAmount: ItemAmount.zero(taxable.currencyCode),
retentionAmount: ItemAmount.zero(taxable.currencyCode),
taxesAmount: ItemAmount.zero(taxable.currencyCode),
};
map.set(key, {
taxes,
taxable: prev.taxable.add(taxable),
ivaAmount: prev.ivaAmount.add(ivaAmount),
recAmount: prev.recAmount.add(recAmount),
retentionAmount: prev.retentionAmount.add(retentionAmount),
taxesAmount: prev.taxesAmount.add(taxesAmount),
});
}
return map;
// Devuelve grupos dinámicos del VO existente (InvoiceTaxGroup)
// Nota: necesitas construir InvoiceTaxGroup aquí o en Proforma.getTaxes().
// Para mantener el ejemplo acotado, se devuelve el map y Proforma lo transforma,
// pero puedes construir aquí directamente si prefieres.
/*return new Collection(
[...map.values()].map((entry) => {
const iva = entry.taxes.iva.unwrap();
const rec = entry.taxes.rec;
const retention = entry.taxes.retention;
// Convertimos a InvoiceAmount en el agregado (o aquí si tienes acceso)
// Aquí asumimos que InvoiceTaxGroup acepta ItemAmount/InvoiceAmount según tu implementación.
// Ajusta según tu VO real.
return InvoiceTaxGroup.create({
iva,
rec,
retention,
taxableAmount: entry.taxable.toInvoiceAmount(), // si existe helper; si no, lo haces en Proforma
}).data;
})
);*/
}
}

View File

@ -1,2 +0,0 @@
export * from "./proforma-tax.entity";
export * from "./proforma-taxes.collection";

View File

@ -1,70 +0,0 @@
import { DomainEntity, type Percentage, type UniqueID } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import type { InvoiceAmount } from "../../../common";
export type ProformaTaxProps = {
taxableAmount: InvoiceAmount;
ivaCode: string;
ivaPercentage: Percentage;
ivaAmount: InvoiceAmount;
recCode: Maybe<string>;
recPercentage: Maybe<Percentage>;
recAmount: Maybe<InvoiceAmount>;
retentionCode: Maybe<string>;
retentionPercentage: Maybe<Percentage>;
retentionAmount: Maybe<InvoiceAmount>;
taxesAmount: InvoiceAmount;
};
export class ProformaTax extends DomainEntity<ProformaTaxProps> {
public static create(props: ProformaTaxProps, id?: UniqueID): Result<ProformaTax, Error> {
return Result.ok(new ProformaTax(props, id));
}
public get taxableAmount(): InvoiceAmount {
return this.props.taxableAmount;
}
public get ivaCode(): string {
return this.props.ivaCode;
}
public get ivaPercentage(): Percentage {
return this.props.ivaPercentage;
}
public get ivaAmount(): InvoiceAmount {
return this.props.ivaAmount;
}
public get recCode(): Maybe<string> {
return this.props.recCode;
}
public get recPercentage(): Maybe<Percentage> {
return this.props.recPercentage;
}
public get recAmount(): Maybe<InvoiceAmount> {
return this.props.recAmount;
}
public get retentionCode(): Maybe<string> {
return this.props.retentionCode;
}
public get retentionPercentage(): Maybe<Percentage> {
return this.props.retentionPercentage;
}
public get retentionAmount(): Maybe<InvoiceAmount> {
return this.props.retentionAmount;
}
public get taxesAmount(): InvoiceAmount {
return this.props.taxesAmount;
}
public getProps(): ProformaTaxProps {
return this.props;
}
}

View File

@ -1,25 +0,0 @@
import type { CurrencyCode, LanguageCode } from "@repo/rdx-ddd";
import { Collection } from "@repo/rdx-utils";
import type { ProformaTax } from "./proforma-tax.entity";
export type ProformaTaxesProps = {
taxes?: ProformaTax[];
languageCode: LanguageCode;
currencyCode: CurrencyCode;
};
export class ProformaTaxes extends Collection<ProformaTax> {
private _languageCode!: LanguageCode;
private _currencyCode!: CurrencyCode;
constructor(props: ProformaTaxesProps) {
super(props.taxes ?? []);
this._languageCode = props.languageCode;
this._currencyCode = props.currencyCode;
}
public static create(props: ProformaTaxesProps): ProformaTaxes {
return new ProformaTaxes(props);
}
}

View File

@ -0,0 +1 @@
export * from "./proforma-tax-calculator";

View File

@ -0,0 +1,55 @@
import type { TaxPercentage } from "@erp/core/api";
import type { Maybe } from "@repo/rdx-utils";
import type { IProformaTaxTotals } from "./proforma-tax-calculator";
/**
* Orden determinista:
* 1) IVA (code, %)
* 2) REC (None primero) (code, %)
* 3) RET (None primero) (code, %)
*/
export function proformaCompareTaxTotals(a: IProformaTaxTotals, b: IProformaTaxTotals): number {
const byIvaCode = compareCode(a.ivaCode, b.ivaCode);
if (byIvaCode !== 0) return byIvaCode;
const byIvaPct = comparePct(a.ivaPercentage, b.ivaPercentage);
if (byIvaPct !== 0) return byIvaPct;
const byRecCode = compareMaybeCodeNoneFirst(a.recCode, b.recCode);
if (byRecCode !== 0) return byRecCode;
const byRecPct = compareMaybePctNoneFirst(a.recPercentage, b.recPercentage);
if (byRecPct !== 0) return byRecPct;
const byRetCode = compareMaybeCodeNoneFirst(a.retentionCode, b.retentionCode);
if (byRetCode !== 0) return byRetCode;
return compareMaybePctNoneFirst(a.retentionPercentage, b.retentionPercentage);
}
function compareCode(a: string, b: string): number {
// Ajusta a tu VO: .value / .code / .toString()
return a.localeCompare(b);
}
function comparePct(a: TaxPercentage, b: TaxPercentage): number {
// Ajusta a tu VO: a.value puede ser number/bigint/string
return a.value - b.value;
}
function compareMaybeCodeNoneFirst(a: Maybe<string>, b: Maybe<string>): number {
if (a.isNone() && b.isNone()) return 0;
if (a.isNone() && b.isSome()) return -1; // None primero
if (a.isSome() && b.isNone()) return 1;
return compareCode(a.unwrap(), b.unwrap());
}
function compareMaybePctNoneFirst(a: Maybe<TaxPercentage>, b: Maybe<TaxPercentage>): number {
if (a.isNone() && b.isNone()) return 0;
if (a.isNone() && b.isSome()) return -1; // None primero
if (a.isSome() && b.isNone()) return 1;
return comparePct(a.unwrap(), b.unwrap());
}

View File

@ -0,0 +1,82 @@
import type { TaxPercentage } from "@erp/core/api";
import { Maybe } from "@repo/rdx-utils";
import { type InvoiceAmount, ItemAmount } from "../../common";
import type { IProformaItems } from "../entities";
type TaxGroupState = {
taxableAmount: ItemAmount;
ivaCode: string;
ivaPercentage: TaxPercentage;
ivaAmount: ItemAmount;
recCode: Maybe<string>;
recPercentage: Maybe<TaxPercentage>;
recAmount: InvoiceAmount;
retentionCode: Maybe<string>;
retentionPercentage: Maybe<TaxPercentage>;
retentionAmount: InvoiceAmount;
};
/**
* Agrupa líneas valoradas por trío (IVA/REC/RET) y acumula importes en scale 4.
*
* Reglas:
* - IVA siempre existe en líneas valoradas (incluye IVA EXENTO 0%).
* - REC y RETENTION pueden ser None.
* - No se recalculan porcentajes (se suma lo ya calculado por línea).
*/
export function proformaComputeTaxGroups(items: IProformaItems): Map<string, TaxGroupState> {
const map = new Map<string, TaxGroupState>();
const currency = items.currencyCode;
for (const item of items.valued()) {
const iva = item.taxes.iva.unwrap(); // siempre existe
const rec = item.taxes.rec;
const retention = item.taxes.retention;
const key = buildTaxGroupKey(iva, rec, retention);
if (!map.has(key)) {
map.set(key, {
taxableAmount: ItemAmount.zero(currency.code),
ivaCode: iva.code,
ivaPercentage: iva.percentage,
ivaAmount: ItemAmount.zero(currency.code),
recCode: rec.isSome() ? Maybe.some(rec.unwrap().code) : Maybe.none(),
recPercentage: rec.isSome() ? Maybe.some(rec.unwrap().percentage) : Maybe.none(),
recAmount: ItemAmount.zero(currency.code),
retentionCode: retention.isSome() ? Maybe.some(retention.unwrap().code) : Maybe.none(),
retentionPercentage: retention.isSome()
? Maybe.some(retention.unwrap().percentage)
: Maybe.none(),
retentionAmount: ItemAmount.zero(currency.code),
});
}
const g = map.get(key)!;
const itemTotals = item.totals();
g.taxableAmount = g.taxableAmount.add(itemTotals.taxableAmount);
g.ivaAmount = g.ivaAmount.add(itemTotals.ivaAmount);
g.recAmount = g.recAmount.add(itemTotals.recAmount);
g.retentionAmount = g.retentionAmount.add(itemTotals.retentionAmount);
}
return map;
}
function buildTaxGroupKey(iva: any, rec: any, retention: any): string {
const recPart = rec.isSome() ? `${rec.unwrap().code}-${rec.unwrap().percentage.value}` : "NULL";
const retentionPart = retention.isSome()
? `${retention.unwrap().code}-${retention.unwrap().percentage.value}`
: "NULL";
return `${iva.code}-${iva.percentage.value}|${recPart}|${retentionPart}`;
}

View File

@ -0,0 +1,72 @@
import type { TaxPercentage } from "@erp/core/api";
import { Collection, type Maybe } from "@repo/rdx-utils";
import { InvoiceAmount, type ItemAmount } from "../../common";
import type { IProformaItems } from "../entities";
import { proformaCompareTaxTotals } from "./proforma-compare-tax-totals";
import { proformaComputeTaxGroups } from "./proforma-compute-tax-groups";
export interface IProformaTaxTotals {
taxableAmount: InvoiceAmount;
ivaCode: string;
ivaPercentage: TaxPercentage;
ivaAmount: InvoiceAmount;
recCode: Maybe<string>;
recPercentage: Maybe<TaxPercentage>;
recAmount: InvoiceAmount;
retentionCode: Maybe<string>;
retentionPercentage: Maybe<TaxPercentage>;
retentionAmount: InvoiceAmount;
taxesAmount: InvoiceAmount;
}
export class ProformaTaxCalculator {
constructor(private readonly items: IProformaItems) {}
public calculate(): Collection<IProformaTaxTotals> {
const groups = proformaComputeTaxGroups(this.items);
const currencyCode = this.items.currencyCode;
const rows = Array.from(groups.values()).map((g) => {
const taxableAmount = this.toInvoiceAmount(g.taxableAmount);
const ivaAmount = this.toInvoiceAmount(g.ivaAmount);
const recAmount = this.toInvoiceAmount(g.recAmount);
const retentionAmount = this.toInvoiceAmount(g.retentionAmount);
const taxesAmount = ivaAmount.add(recAmount).subtract(retentionAmount);
return {
taxableAmount,
ivaCode: g.ivaCode,
ivaPercentage: g.ivaPercentage,
ivaAmount,
recCode: g.recCode,
recPercentage: g.recPercentage,
recAmount,
retentionCode: g.retentionCode,
retentionPercentage: g.retentionPercentage,
retentionAmount,
taxesAmount,
} as const;
});
rows.sort(proformaCompareTaxTotals);
return new Collection(rows);
}
private toInvoiceAmount(amount: ItemAmount): InvoiceAmount {
return InvoiceAmount.create({
value: amount.convertScale(InvoiceAmount.DEFAULT_SCALE).value,
currency_code: this.items.currencyCode.code,
}).data;
}
}

View File

@ -1,2 +1 @@
export * from "./proforma-item-tax-group.vo";
export * from "./proforma-tax-group.vo";
export * from "./proforma-item-taxes.vo";

View File

@ -4,15 +4,45 @@ import { type Maybe, Result } from "@repo/rdx-utils";
import { ItemAmount } from "../../common/value-objects";
export interface ProformaItemTaxGroupProps {
export type ProformaItemTaxesProps = {
iva: Maybe<Tax>; // si existe
rec: Maybe<Tax>; // si existe
retention: Maybe<Tax>; // si existe
};
export interface IProformaItemTaxes {
iva: Maybe<Tax>; // si existe
rec: Maybe<Tax>; // si existe
retention: Maybe<Tax>; // si existe
toKey(): string; // Clave para representar un trío.
}
export class ProformaItemTaxGroup extends ValueObject<ProformaItemTaxGroupProps> {
static create(props: ProformaItemTaxGroupProps) {
return Result.ok(new ProformaItemTaxGroup(props));
export class ProformaItemTaxes
extends ValueObject<ProformaItemTaxesProps>
implements IProformaItemTaxes
{
static create(props: ProformaItemTaxesProps) {
return Result.ok(new ProformaItemTaxes(props));
}
toKey(): string {
const ivaCode = this.props.iva.match(
(iva) => iva.code,
() => "#"
);
const recCode = this.props.rec.match(
(rec) => rec.code,
() => "#"
);
const retentionCode = this.props.retention.match(
(retention) => retention.code,
() => "#"
);
return `${ivaCode};${recCode};${retentionCode}`;
}
calculateAmounts(taxableAmount: ItemAmount) {
@ -46,37 +76,6 @@ export class ProformaItemTaxGroup extends ValueObject<ProformaItemTaxGroupProps>
return this.props.retention;
}
public getCodesArray(): string[] {
const codes: string[] = [];
this.props.iva.match(
(iva) => codes.push(iva.code),
() => {
//
}
);
this.props.rec.match(
(rec) => codes.push(rec.code),
() => {
//
}
);
this.props.retention.match(
(retention) => codes.push(retention.code),
() => {
//
}
);
return codes;
}
public getCodesToString(): string {
return this.getCodesArray().join(", ");
}
getProps() {
return this.props;
}

View File

@ -1,66 +0,0 @@
import type { Tax } from "@erp/core/api";
import { ValueObject } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import type { InvoiceAmount } from "../../common";
export type ProformaTaxGroupProps = {
taxableAmount: InvoiceAmount;
iva: Tax;
ivaAmount: InvoiceAmount;
rec: Maybe<Tax>; // si existe
recAmount: Maybe<InvoiceAmount>;
retention: Maybe<Tax>; // si existe
retentionAmount: Maybe<InvoiceAmount>;
taxesAmount: InvoiceAmount;
};
export class ProformaTaxGroup extends ValueObject<ProformaTaxGroupProps> {
static create(props: ProformaTaxGroupProps) {
return Result.ok(new ProformaTaxGroup(props));
}
get taxableAmount(): InvoiceAmount {
return this.props.taxableAmount;
}
get iva(): Tax {
return this.props.iva;
}
get ivaAmount(): InvoiceAmount {
return this.props.ivaAmount;
}
get rec(): Maybe<Tax> {
return this.props.rec;
}
get recAmount(): Maybe<InvoiceAmount> {
return this.props.recAmount;
}
get retention(): Maybe<Tax> {
return this.props.retention;
}
get retentionAmount(): Maybe<InvoiceAmount> {
return this.props.retentionAmount;
}
get taxesAmount(): InvoiceAmount {
return this.props.taxesAmount;
}
getProps() {
return this.props;
}
toPrimitive() {
return this.getProps();
}
}

View File

@ -37,12 +37,12 @@ export class CustomerInvoiceItemModel extends Model<
declare subtotal_amount_scale: number;
// Discount percentage
declare discount_percentage_value: CreationOptional<number | null>;
declare discount_percentage_scale: number;
declare item_discount_percentage_value: CreationOptional<number | null>;
declare item_discount_percentage_scale: number;
// Discount amount
declare discount_amount_value: number;
declare discount_amount_scale: number;
declare item_discount_amount_value: number;
declare item_discount_amount_scale: number;
// Porcentaje de descuento global proporcional a esta línea.
declare global_discount_percentage_value: CreationOptional<number | null>;
@ -86,7 +86,6 @@ export class CustomerInvoiceItemModel extends Model<
declare retention_percentage_value: CreationOptional<number | null>;
declare retention_percentage_scale: number;
// Retention amount
declare retention_amount_value: number;
declare retention_amount_scale: number;
@ -185,25 +184,25 @@ export default (database: Sequelize) => {
defaultValue: 4,
},
discount_percentage_value: {
item_discount_percentage_value: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
},
discount_percentage_scale: {
item_discount_percentage_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 2,
},
discount_amount_value: {
item_discount_amount_value: {
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: false,
defaultValue: 0,
},
discount_amount_scale: {
item_discount_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 4,

View File

@ -35,7 +35,6 @@ export class CustomerInvoiceTaxModel extends Model<
declare iva_percentage_scale: number;
// IVA amount
declare iva_amount_value: number;
declare iva_amount_scale: number;

View File

@ -73,7 +73,7 @@ export class CustomerInvoiceModel extends Model<
declare items_discount_amount_scale: number;
// Global/header discount percentage
declare global_discount_percentage_value: number;
declare global_discount_percentage_value: CreationOptional<number | null>;
declare global_discount_percentage_scale: number;
// Global/header discount amount

View File

@ -2,7 +2,6 @@ import { type MapperParamsType, SequelizeDomainMapper } from "@erp/core/api";
import {
CurrencyCode,
LanguageCode,
Percentage,
TextValue,
UniqueID,
UtcDate,
@ -16,6 +15,7 @@ import { Maybe, Result, isNullishOrEmpty } from "@repo/rdx-utils";
import type { IIssuedInvoiceDomainMapper } from "../../../../../../application";
import {
DiscountPercentage,
InvoiceAmount,
InvoiceNumber,
InvoicePaymentMethod,
@ -68,8 +68,6 @@ export class SequelizeIssuedInvoiceDomainMapper
const customerId = extractOrPushError(UniqueID.create(raw.customer_id), "customer_id", errors);
const isIssuedInvoice = Boolean(raw.is_proforma);
const proformaId = extractOrPushError(
maybeFromNullableResult(raw.proforma_id, (v) => UniqueID.create(v)),
"proforma_id",
@ -181,9 +179,8 @@ export class SequelizeIssuedInvoiceDomainMapper
// % descuento global (VO)
const globalDiscountPercentage = extractOrPushError(
Percentage.create({
DiscountPercentage.create({
value: Number(raw.global_discount_percentage_value ?? 0),
scale: Number(raw.global_discount_percentage_scale ?? 2),
}),
"global_discount_percentage_value",
errors
@ -265,7 +262,6 @@ export class SequelizeIssuedInvoiceDomainMapper
invoiceId,
companyId,
customerId,
isIssuedInvoice,
proformaId,
status,
series,
@ -294,50 +290,42 @@ export class SequelizeIssuedInvoiceDomainMapper
}
public mapToDomain(
source: CustomerInvoiceModel,
raw: CustomerInvoiceModel,
params?: MapperParamsType
): Result<IssuedInvoice, Error> {
try {
const errors: ValidationErrorDetail[] = [];
// 1) Valores escalares (atributos generales)
const attributes = this._mapAttributesToDomain(source, { errors, ...params });
const attributes = this._mapAttributesToDomain(raw, { errors, ...params });
// 2) Recipient (snapshot en la factura o include)
const recipientResult = this._recipientMapper.mapToDomain(source, {
const recipientResult = this._recipientMapper.mapToDomain(raw, {
errors,
attributes,
...params,
});
// 3) Verifactu (snapshot en la factura o include)
const verifactuResult = this._verifactuMapper.mapToDomain(source.verifactu, {
const verifactuResult = this._verifactuMapper.mapToDomain(raw.verifactu, {
errors,
attributes,
...params,
});
// 4) Items (colección)
const itemsResults = this._itemsMapper.mapToDomainCollection(
source.items,
source.items.length,
{
errors,
attributes,
...params,
}
);
const itemsResults = this._itemsMapper.mapToDomainCollection(raw.items, raw.items.length, {
errors,
attributes,
...params,
});
// 5) Taxes (colección)
const taxesResults = this._taxesMapper.mapToDomainCollection(
source.taxes,
source.taxes.length,
{
errors,
attributes,
...params,
}
);
const taxesResults = this._taxesMapper.mapToDomainCollection(raw.taxes, raw.taxes.length, {
errors,
attributes,
...params,
});
// 6) Si hubo errores de mapeo, devolvemos colección de validación
if (errors.length > 0) {
@ -499,7 +487,6 @@ export class SequelizeIssuedInvoiceDomainMapper
reference: maybeToNullable(source.reference, (reference) => reference),
description: maybeToNullable(source.description, (description) => description),
notes: maybeToNullable(source.notes, (v) => v.toPrimitive()),
payment_method_id: maybeToNullable(

View File

@ -13,6 +13,7 @@ import {
import { Result } from "@repo/rdx-utils";
import {
DiscountPercentage,
type IssuedInvoice,
IssuedInvoiceItem,
type IssuedInvoiceItemProps,
@ -94,25 +95,25 @@ export class SequelizeIssuedInvoiceItemDomainMapper extends SequelizeDomainMappe
);
const itemDiscountPercentage = extractOrPushError(
maybeFromNullableResult(raw.discount_percentage_value, (v) =>
maybeFromNullableResult(raw.item_discount_percentage_value, (v) =>
ItemDiscountPercentage.create({ value: v })
),
`items[${index}].discount_percentage_value`,
`items[${index}].item_discount_percentage_value`,
errors
);
const itemDiscountAmount = extractOrPushError(
ItemAmount.create({
value: raw.discount_amount_value,
value: raw.item_discount_amount_value,
currency_code: attributes.currencyCode?.code,
}),
`items[${index}].discount_amount_value`,
`items[${index}].item_discount_amount_value`,
errors
);
const globalDiscountPercentage = extractOrPushError(
maybeFromNullableResult(raw.global_discount_percentage_value, (v) =>
ItemDiscountPercentage.create({ value: v })
DiscountPercentage.create({ value: v })
),
`items[${index}].global_discount_percentage_value`,
errors
@ -357,16 +358,16 @@ export class SequelizeIssuedInvoiceItemDomainMapper extends SequelizeDomainMappe
subtotal_amount_value: source.subtotalAmount.toPrimitive().value,
subtotal_amount_scale: source.subtotalAmount.toPrimitive().scale,
discount_percentage_value: maybeToNullable(
item_discount_percentage_value: maybeToNullable(
source.itemDiscountPercentage,
(v) => v.toPrimitive().value
),
discount_percentage_scale:
item_discount_percentage_scale:
maybeToNullable(source.itemDiscountPercentage, (v) => v.toPrimitive().scale) ??
ItemDiscountPercentage.DEFAULT_SCALE,
discount_amount_value: source.itemDiscountAmount.toPrimitive().value,
discount_amount_scale: source.itemDiscountAmount.toPrimitive().scale,
item_discount_amount_value: source.itemDiscountAmount.toPrimitive().value,
item_discount_amount_scale: source.itemDiscountAmount.toPrimitive().scale,
global_discount_percentage_value: maybeToNullable(
source.globalDiscountPercentage,

View File

@ -1,6 +1,7 @@
import type { JsonTaxCatalogProvider } from "@erp/core";
import { type MapperParamsType, SequelizeDomainMapper } from "@erp/core/api";
import {
Percentage,
UniqueID,
ValidationErrorCollection,
type ValidationErrorDetail,
@ -14,10 +15,12 @@ import { Result } from "@repo/rdx-utils";
import {
InvoiceAmount,
InvoiceTaxPercentage,
type IssuedInvoice,
type IssuedInvoiceProps,
IssuedInvoiceTax,
ItemAmount,
ItemDiscountPercentage,
TaxPercentage,
} from "../../../../../../domain";
import type {
CustomerInvoiceTaxCreationAttributes,
@ -57,7 +60,7 @@ export class SequelizeIssuedInvoiceTaxesDomainMapper extends SequelizeDomainMapp
}
public mapToDomain(
source: CustomerInvoiceTaxModel,
raw: CustomerInvoiceTaxModel,
params?: MapperParamsType
): Result<IssuedInvoiceTax, Error> {
const { errors, index, attributes } = params as {
@ -68,18 +71,18 @@ export class SequelizeIssuedInvoiceTaxesDomainMapper extends SequelizeDomainMapp
const taxableAmount = extractOrPushError(
InvoiceAmount.create({
value: source.taxable_amount_value,
value: raw.taxable_amount_value,
currency_code: attributes.currencyCode?.code,
}),
`taxes[${index}].taxable_amount_value`,
errors
);
const ivaCode = source.iva_code;
const ivaCode = raw.iva_code;
const ivaPercentage = extractOrPushError(
InvoiceTaxPercentage.create({
value: source.iva_percentage_value,
TaxPercentage.create({
value: raw.iva_percentage_value,
}),
`taxes[${index}].iva_percentage_value`,
errors
@ -87,52 +90,52 @@ export class SequelizeIssuedInvoiceTaxesDomainMapper extends SequelizeDomainMapp
const ivaAmount = extractOrPushError(
InvoiceAmount.create({
value: source.iva_amount_value,
value: raw.iva_amount_value,
currency_code: attributes.currencyCode?.code,
}),
`taxes[${index}].iva_amount_value`,
errors
);
const recCode = maybeFromNullableOrEmptyString(source.rec_code);
const recCode = maybeFromNullableOrEmptyString(raw.rec_code);
const recPercentage = extractOrPushError(
maybeFromNullableResult(source.rec_percentage_value, (value) =>
InvoiceTaxPercentage.create({ value })
),
maybeFromNullableResult(raw.rec_percentage_value, (value) => TaxPercentage.create({ value })),
`taxes[${index}].rec_percentage_value`,
errors
);
const recAmount = extractOrPushError(
maybeFromNullableResult(source.rec_amount_value, (value) =>
InvoiceAmount.create({ value, currency_code: attributes.currencyCode?.code })
),
InvoiceAmount.create({
value: raw.rec_amount_value,
currency_code: attributes.currencyCode?.code,
}),
`taxes[${index}].rec_amount_value`,
errors
);
const retentionCode = maybeFromNullableOrEmptyString(source.retention_code);
const retentionCode = maybeFromNullableOrEmptyString(raw.retention_code);
const retentionPercentage = extractOrPushError(
maybeFromNullableResult(source.retention_percentage_value, (value) =>
InvoiceTaxPercentage.create({ value })
maybeFromNullableResult(raw.retention_percentage_value, (value) =>
TaxPercentage.create({ value })
),
`taxes[${index}].retention_percentage_value`,
errors
);
const retentionAmount = extractOrPushError(
maybeFromNullableResult(source.retention_amount_value, (value) =>
InvoiceAmount.create({ value, currency_code: attributes.currencyCode?.code })
),
InvoiceAmount.create({
value: raw.retention_amount_value,
currency_code: attributes.currencyCode?.code,
}),
`taxes[${index}].retention_amount_value`,
errors
);
const taxesAmount = extractOrPushError(
InvoiceAmount.create({
value: source.taxes_amount_value,
value: raw.taxes_amount_value,
currency_code: attributes.currencyCode?.code,
}),
`taxes[${index}].taxes_amount_value`,
@ -208,10 +211,11 @@ export class SequelizeIssuedInvoiceTaxesDomainMapper extends SequelizeDomainMapp
rec_percentage_value: maybeToNullable(source.recPercentage, (v) => v.toPrimitive().value),
rec_percentage_scale:
maybeToNullable(source.recPercentage, (v) => v.toPrimitive().scale) ?? 2,
maybeToNullable(source.recPercentage, (v) => v.toPrimitive().scale) ??
ItemDiscountPercentage.DEFAULT_SCALE,
rec_amount_value: maybeToNullable(source.recAmount, (v) => v.toPrimitive().value),
rec_amount_scale: maybeToNullable(source.recAmount, (v) => v.toPrimitive().scale) ?? 4,
rec_amount_value: source.recAmount.toPrimitive().value,
rec_amount_scale: source.recAmount.toPrimitive().scale ?? ItemAmount.DEFAULT_SCALE,
// RET
retention_code: maybeToNullableString(source.retentionCode),
@ -221,14 +225,12 @@ export class SequelizeIssuedInvoiceTaxesDomainMapper extends SequelizeDomainMapp
(v) => v.toPrimitive().value
),
retention_percentage_scale:
maybeToNullable(source.retentionPercentage, (v) => v.toPrimitive().scale) ?? 2,
maybeToNullable(source.retentionPercentage, (v) => v.toPrimitive().scale) ??
Percentage.DEFAULT_SCALE,
retention_amount_value: maybeToNullable(
source.retentionAmount,
(v) => v.toPrimitive().value
),
retention_amount_value: source.retentionAmount.toPrimitive().value,
retention_amount_scale:
maybeToNullable(source.retentionAmount, (v) => v.toPrimitive().scale) ?? 4,
source.retentionAmount.toPrimitive().scale ?? ItemAmount.DEFAULT_SCALE,
// TOTAL
taxes_amount_value: source.taxesAmount.value,

View File

@ -2,7 +2,6 @@ import { type MapperParamsType, SequelizeDomainMapper } from "@erp/core/api";
import {
CurrencyCode,
LanguageCode,
Percentage,
TextValue,
UniqueID,
UtcDate,
@ -16,6 +15,7 @@ import { Maybe, Result, isNullishOrEmpty } from "@repo/rdx-utils";
import type { IProformaDomainMapper } from "../../../../../../application";
import {
DiscountPercentage,
InvoiceNumber,
InvoicePaymentMethod,
InvoiceSerie,
@ -46,86 +46,80 @@ export class SequelizeProformaDomainMapper
this._itemsMapper = new SequelizeProformaItemDomainMapper(params);
this._recipientMapper = new SequelizeProformaRecipientDomainMapper();
this._taxesMapper = new SequelizeProformaTaxesDomainMapper();
this._taxesMapper = new SequelizeProformaTaxesDomainMapper(params);
}
private _mapAttributesToDomain(source: CustomerInvoiceModel, params?: MapperParamsType) {
private _mapAttributesToDomain(raw: CustomerInvoiceModel, params?: MapperParamsType) {
const { errors } = params as {
errors: ValidationErrorDetail[];
};
const invoiceId = extractOrPushError(UniqueID.create(source.id), "id", errors);
const companyId = extractOrPushError(UniqueID.create(source.company_id), "company_id", errors);
const invoiceId = extractOrPushError(UniqueID.create(raw.id), "id", errors);
const companyId = extractOrPushError(UniqueID.create(raw.company_id), "company_id", errors);
const customerId = extractOrPushError(
UniqueID.create(source.customer_id),
"customer_id",
errors
);
const isProforma = Boolean(source.is_proforma);
const customerId = extractOrPushError(UniqueID.create(raw.customer_id), "customer_id", errors);
const proformaId = extractOrPushError(
maybeFromNullableResult(source.proforma_id, (v) => UniqueID.create(v)),
maybeFromNullableResult(raw.proforma_id, (v) => UniqueID.create(v)),
"proforma_id",
errors
);
const status = extractOrPushError(InvoiceStatus.create(source.status), "status", errors);
const status = extractOrPushError(InvoiceStatus.create(raw.status), "status", errors);
const series = extractOrPushError(
maybeFromNullableResult(source.series, (v) => InvoiceSerie.create(v)),
maybeFromNullableResult(raw.series, (v) => InvoiceSerie.create(v)),
"series",
errors
);
const invoiceNumber = extractOrPushError(
InvoiceNumber.create(source.invoice_number),
InvoiceNumber.create(raw.invoice_number),
"invoice_number",
errors
);
// Fechas
const invoiceDate = extractOrPushError(
UtcDate.createFromISO(source.invoice_date),
UtcDate.createFromISO(raw.invoice_date),
"invoice_date",
errors
);
const operationDate = extractOrPushError(
maybeFromNullableResult(source.operation_date, (v) => UtcDate.createFromISO(v)),
maybeFromNullableResult(raw.operation_date, (v) => UtcDate.createFromISO(v)),
"operation_date",
errors
);
// Idioma / divisa
const languageCode = extractOrPushError(
LanguageCode.create(source.language_code),
LanguageCode.create(raw.language_code),
"language_code",
errors
);
const currencyCode = extractOrPushError(
CurrencyCode.create(source.currency_code),
CurrencyCode.create(raw.currency_code),
"currency_code",
errors
);
// Textos opcionales
const reference = extractOrPushError(
maybeFromNullableResult(source.reference, (value) => Result.ok(String(value))),
maybeFromNullableResult(raw.reference, (value) => Result.ok(String(value))),
"reference",
errors
);
const description = extractOrPushError(
maybeFromNullableResult(source.description, (value) => Result.ok(String(value))),
maybeFromNullableResult(raw.description, (value) => Result.ok(String(value))),
"description",
errors
);
const notes = extractOrPushError(
maybeFromNullableResult(source.notes, (value) => TextValue.create(value)),
maybeFromNullableResult(raw.notes, (value) => TextValue.create(value)),
"notes",
errors
);
@ -133,16 +127,16 @@ export class SequelizeProformaDomainMapper
// Método de pago (VO opcional con id + descripción)
let paymentMethod = Maybe.none<InvoicePaymentMethod>();
if (!isNullishOrEmpty(source.payment_method_id)) {
if (!isNullishOrEmpty(raw.payment_method_id)) {
const paymentId = extractOrPushError(
UniqueID.create(String(source.payment_method_id)),
UniqueID.create(String(raw.payment_method_id)),
"paymentMethod.id",
errors
);
const paymentVO = extractOrPushError(
InvoicePaymentMethod.create(
{ paymentDescription: String(source.payment_method_description ?? "") },
{ paymentDescription: String(raw.payment_method_description ?? "") },
paymentId ?? undefined
),
"payment_method_description",
@ -154,13 +148,12 @@ export class SequelizeProformaDomainMapper
}
}
// % descuento (VO)
const discountPercentage = extractOrPushError(
Percentage.create({
value: Number(source.discount_percentage_value ?? 0),
scale: Number(source.discount_percentage_scale ?? 2),
// % descuento global (VO)
const globalDiscountPercentage = extractOrPushError(
DiscountPercentage.create({
value: Number(raw.global_discount_percentage_value ?? 0),
}),
"discount_percentage_value",
"global_discount_percentage_value",
errors
);
@ -168,7 +161,6 @@ export class SequelizeProformaDomainMapper
invoiceId,
companyId,
customerId,
isProforma,
proformaId,
status,
series,
@ -180,38 +172,35 @@ export class SequelizeProformaDomainMapper
notes,
languageCode,
currencyCode,
discountPercentage,
paymentMethod,
globalDiscountPercentage,
};
}
public mapToDomain(
source: CustomerInvoiceModel,
raw: CustomerInvoiceModel,
params?: MapperParamsType
): Result<Proforma, Error> {
try {
const errors: ValidationErrorDetail[] = [];
// 1) Valores escalares (atributos generales)
const attributes = this._mapAttributesToDomain(source, { errors, ...params });
const attributes = this._mapAttributesToDomain(raw, { errors, ...params });
// 2) Recipient (snapshot en la factura o include)
const recipientResult = this._recipientMapper.mapToDomain(source, {
const recipientResult = this._recipientMapper.mapToDomain(raw, {
errors,
attributes,
...params,
});
// 3) Items (colección)
const itemsResults = this._itemsMapper.mapToDomainCollection(
source.items,
source.items.length,
{
errors,
attributes,
...params,
}
);
const itemsResults = this._itemsMapper.mapToDomainCollection(raw.items, raw.items.length, {
errors,
attributes,
...params,
});
// 4) Si hubo errores de mapeo, devolvemos colección de validación
if (errors.length > 0) {
@ -225,14 +214,13 @@ export class SequelizeProformaDomainMapper
const items = ProformaItems.create({
languageCode: attributes.languageCode!,
currencyCode: attributes.currencyCode!,
globalDiscountPercentage: attributes.discountPercentage!,
globalDiscountPercentage: attributes.globalDiscountPercentage!,
items: itemsResults.data.getAll(),
});
const invoiceProps: ProformaProps = {
companyId: attributes.companyId!,
isProforma: attributes.isProforma,
status: attributes.status!,
series: attributes.series!,
invoiceNumber: attributes.invoiceNumber!,
@ -249,7 +237,7 @@ export class SequelizeProformaDomainMapper
languageCode: attributes.languageCode!,
currencyCode: attributes.currencyCode!,
globalDiscountPercentage: attributes.discountPercentage!,
globalDiscountPercentage: attributes.globalDiscountPercentage!,
paymentMethod: attributes.paymentMethod!,
@ -331,11 +319,12 @@ export class SequelizeProformaDomainMapper
company_id: source.companyId.toPrimitive(),
// Flags / estado / serie / número
is_proforma: source.isProforma,
is_proforma: true,
status: source.status.toPrimitive(),
proforma_id: null,
series: maybeToNullable(source.series, (v) => v.toPrimitive()),
invoice_number: source.invoiceNumber.toPrimitive(),
invoice_date: source.invoiceDate.toPrimitive(),
operation_date: maybeToNullable(source.operationDate, (v) => v.toPrimitive()),
language_code: source.languageCode.toPrimitive(),
@ -345,6 +334,15 @@ export class SequelizeProformaDomainMapper
description: maybeToNullable(source.description, (description) => description),
notes: maybeToNullable(source.notes, (v) => v.toPrimitive()),
payment_method_id: maybeToNullable(
source.paymentMethod,
(payment) => payment.toObjectString().id
),
payment_method_description: maybeToNullable(
source.paymentMethod,
(payment) => payment.toObjectString().payment_description
),
subtotal_amount_value: allAmounts.subtotalAmount.value,
subtotal_amount_scale: allAmounts.subtotalAmount.scale,
@ -369,15 +367,6 @@ export class SequelizeProformaDomainMapper
total_amount_value: allAmounts.totalAmount.value,
total_amount_scale: allAmounts.totalAmount.scale,
payment_method_id: maybeToNullable(
source.paymentMethod,
(payment) => payment.toObjectString().id
),
payment_method_description: maybeToNullable(
source.paymentMethod,
(payment) => payment.toObjectString().payment_description
),
customer_id: source.customerId.toPrimitive(),
...recipient,

View File

@ -1,3 +1,4 @@
import type { JsonTaxCatalogProvider } from "@erp/core";
import { type MapperParamsType, SequelizeDomainMapper } from "@erp/core/api";
import { UniqueID, type ValidationErrorDetail, maybeToNullable } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
@ -25,6 +26,21 @@ export class SequelizeProformaTaxesDomainMapper extends SequelizeDomainMapper<
CustomerInvoiceTaxCreationAttributes,
InvoiceTaxGroup
> {
private taxCatalog!: JsonTaxCatalogProvider;
constructor(params: MapperParamsType) {
super();
const { taxCatalog } = params as {
taxCatalog: JsonTaxCatalogProvider;
};
if (!taxCatalog) {
throw new Error('taxCatalog not defined ("SequelizeProformaTaxesDomainMapper")');
}
this.taxCatalog = taxCatalog;
}
public mapToDomain(
source: CustomerInvoiceTaxModel,
params?: MapperParamsType

View File

@ -28,6 +28,10 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"],
"include": [
"src",
"../core/src/api/domain/value-objects/tax-percentage.vo.ts",
"../core/src/api/domain/value-objects/discount-percentage.vo.ts"
],
"exclude": ["node_modules"]
}

View File

@ -1,6 +1,5 @@
import { Result } from "@repo/rdx-utils";
import { z } from "zod/v4";
import { translateZodValidationError } from "../helpers";
import { ValueObject } from "./value-object";
interface TaxCodeProps {
@ -28,7 +27,8 @@ export class TaxCode extends ValueObject<TaxCodeProps> {
}
static create(value: string) {
const valueIsValid = TaxCode.validate(value);
throw new Error("DEPRECATED -> ¿DEBERÍA USARSE STRING COMO EN LAS FACTURAS?");
/*const valueIsValid = TaxCode.validate(value);
if (!valueIsValid.success) {
return Result.fail(
@ -36,7 +36,7 @@ export class TaxCode extends ValueObject<TaxCodeProps> {
);
}
// biome-ignore lint/style/noNonNullAssertion: <explanation>
return Result.ok(new TaxCode({ value: valueIsValid.data! }));
return Result.ok(new TaxCode({ value: valueIsValid.data! }));*/
}
getProps(): string {

View File

@ -31,7 +31,7 @@ export class Collection<T> {
addCollection(collection: Collection<T>): boolean {
this.items.push(...collection.items);
if (this.totalItems !== null) {
this.totalItems = this.totalItems + collection.totalItems;
this.totalItems += collection.totalItems;
}
return true;
}

7
uecko-erp.code-workspace Normal file
View File

@ -0,0 +1,7 @@
{
"folders": [
{
"path": "."
}
]
}