Arreglos de impuestos en líneas y cabeceras

This commit is contained in:
David Arranz 2025-12-04 19:31:40 +01:00
parent cb9a547351
commit a42e762b17
30 changed files with 1294 additions and 589 deletions

View File

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

View File

@ -1,8 +1,9 @@
import { Collection } from "@repo/rdx-utils";
import { Tax } from "./tax";
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 this(items);
return new Taxes(items);
}
}

View File

@ -58,7 +58,7 @@ export function mapDTOToCustomerInvoiceItemsProps(
description: description,
quantity: quantity,
unitAmount: unitAmount,
discountPercentage: discountPercentage,
itemDiscountPercentage: discountPercentage,
//currencyCode,
//languageCode,
//taxes:

View File

@ -14,7 +14,7 @@ export class IssuedInvoiceItemsFullPresenter extends Presenter {
invoiceItem: CustomerInvoiceItem,
index: number
): GetIssuedInvoiceItemByInvoiceIdResponseDTO {
const allAmounts = invoiceItem.getAllAmounts();
const allAmounts = invoiceItem.calculateAllAmounts();
return {
id: invoiceItem.id.toPrimitive(),
@ -34,15 +34,58 @@ export class IssuedInvoiceItemsFullPresenter extends Presenter {
subtotal_amount: allAmounts.subtotalAmount.toObjectString(),
discount_percentage: invoiceItem.discountPercentage.match(
discount_percentage: invoiceItem.itemDiscountPercentage.match(
(discountPercentage) => discountPercentage.toObjectString(),
() => ({ value: "", scale: "" })
),
discount_amount: allAmounts.discountAmount.toObjectString(),
discount_amount: allAmounts.itemDiscountAmount.toObjectString(),
global_discount_percentage: invoiceItem.globalDiscountPercentage.match(
(discountPercentage) => discountPercentage.toObjectString(),
() => ({ value: "", scale: "" })
),
global_discount_amount: allAmounts.globalDiscountAmount.toObjectString(),
taxable_amount: allAmounts.taxableAmount.toObjectString(),
tax_codes: invoiceItem.taxes.getCodesArray(),
iva_code: invoiceItem.taxes.iva.match(
(iva) => iva.code,
() => ""
),
iva_percentage: invoiceItem.taxes.iva.match(
(iva) => iva.percentage.toObjectString(),
() => ({ value: "", scale: "" })
),
iva_amount: allAmounts.ivaAmount.toObjectString(),
rec_code: invoiceItem.taxes.rec.match(
(rec) => rec.code,
() => ""
),
rec_percentage: invoiceItem.taxes.rec.match(
(rec) => rec.percentage.toObjectString(),
() => ({ value: "", scale: "" })
),
rec_amount: allAmounts.recAmount.toObjectString(),
retention_code: invoiceItem.taxes.retention.match(
(retention) => retention.code,
() => ""
),
retention_percentage: invoiceItem.taxes.retention.match(
(retention) => retention.percentage.toObjectString(),
() => ({ value: "", scale: "" })
),
retention_amount: allAmounts.retentionAmount.toObjectString(),
taxes_amount: allAmounts.taxesAmount.toObjectString(),
total_amount: allAmounts.totalAmount.toObjectString(),

View File

@ -2,7 +2,7 @@ import { Presenter } from "@erp/core/api";
import { toEmptyString } from "@repo/rdx-ddd";
import type { GetIssuedInvoiceByIdResponseDTO } from "../../../../../common/dto";
import type { CustomerInvoice } from "../../../../domain";
import { type CustomerInvoice, InvoiceAmount } from "../../../../domain";
import type { IssuedInvoiceItemsFullPresenter } from "./issued-invoice-items.full.presenter";
import type { IssuedInvoiceRecipientFullPresenter } from "./issued-invoice-recipient.full.presenter";
@ -31,7 +31,7 @@ export class IssuedInvoiceFullPresenter extends Presenter<
const recipient = recipientPresenter.toOutput(invoice);
const items = itemsPresenter.toOutput(invoice.items);
const verifactu = verifactuPresenter.toOutput(invoice);
const allAmounts = invoice.getAllAmounts();
const allAmounts = invoice.calculateAllAmounts();
const payment = invoice.paymentMethod.match(
(payment) => {
@ -44,11 +44,49 @@ export class IssuedInvoiceFullPresenter extends Presenter<
() => undefined
);
const invoiceTaxes = invoice.getTaxes().map((taxItem) => {
let totalIvaAmount = InvoiceAmount.zero(invoice.currencyCode.code);
let totalRecAmount = InvoiceAmount.zero(invoice.currencyCode.code);
let totalRetentionAmount = InvoiceAmount.zero(invoice.currencyCode.code);
const invoiceTaxes = invoice.getTaxes().map((taxGroup) => {
const { ivaAmount, recAmount, retentionAmount, totalAmount } = taxGroup.calculateAmounts();
totalIvaAmount = totalIvaAmount.add(ivaAmount);
totalRecAmount = totalRecAmount.add(recAmount);
totalRetentionAmount = totalRetentionAmount.add(retentionAmount);
return {
tax_code: taxItem.tax.code,
taxable_amount: taxItem.taxableAmount.toObjectString(),
taxes_amount: taxItem.taxesAmount.toObjectString(),
taxable_amount: taxGroup.taxableAmount.toObjectString(),
iva_code: taxGroup.iva.code,
iva_percentage: taxGroup.iva.percentage.toObjectString(),
iva_amount: ivaAmount.toObjectString(),
rec_code: taxGroup.rec.match(
(rec) => rec.code,
() => ""
),
rec_percentage: taxGroup.rec.match(
(rec) => rec.percentage.toObjectString(),
() => ({ value: "", scale: "" })
),
rec_amount: recAmount.toObjectString(),
retention_code: taxGroup.retention.match(
(retention) => retention.code,
() => ""
),
retention_percentage: taxGroup.retention.match(
(retention) => retention.percentage.toObjectString(),
() => ({ value: "", scale: "" })
),
retention_amount: retentionAmount.toObjectString(),
taxes_amount: totalAmount.toObjectString(),
};
});
@ -74,20 +112,25 @@ export class IssuedInvoiceFullPresenter extends Presenter<
customer_id: invoice.customerId.toString(),
recipient,
taxes: invoiceTaxes,
payment_method: payment,
subtotal_amount: allAmounts.subtotalAmount.toObjectString(),
items_discount_amount: allAmounts.itemDiscountAmount.toObjectString(),
discount_percentage: invoice.discountPercentage.toObjectString(),
discount_amount: allAmounts.headerDiscountAmount.toObjectString(),
discount_amount: allAmounts.globalDiscountAmount.toObjectString(),
taxable_amount: allAmounts.taxableAmount.toObjectString(),
iva_amount: totalIvaAmount.toObjectString(),
rec_amount: totalRecAmount.toObjectString(),
retention_amount: totalRetentionAmount.toObjectString(),
taxes_amount: allAmounts.taxesAmount.toObjectString(),
total_amount: allAmounts.totalAmount.toObjectString(),
taxes: invoiceTaxes,
verifactu,
items,

View File

@ -12,7 +12,7 @@ export class ProformaItemsFullPresenter extends Presenter {
proformaItem: CustomerInvoiceItem,
index: number
): GetProformaItemByIdResponseDTO {
const allAmounts = proformaItem.getAllAmounts();
const allAmounts = proformaItem.calculateAllAmounts();
return {
id: proformaItem.id.toPrimitive(),
@ -32,15 +32,58 @@ export class ProformaItemsFullPresenter extends Presenter {
subtotal_amount: allAmounts.subtotalAmount.toObjectString(),
discount_percentage: proformaItem.discountPercentage.match(
discount_percentage: proformaItem.itemDiscountPercentage.match(
(discountPercentage) => discountPercentage.toObjectString(),
() => ({ value: "", scale: "" })
),
discount_amount: allAmounts.discountAmount.toObjectString(),
discount_amount: allAmounts.itemDiscountAmount.toObjectString(),
global_discount_percentage: proformaItem.globalDiscountPercentage.match(
(discountPercentage) => discountPercentage.toObjectString(),
() => ({ value: "", scale: "" })
),
global_discount_amount: allAmounts.globalDiscountAmount.toObjectString(),
taxable_amount: allAmounts.taxableAmount.toObjectString(),
tax_codes: proformaItem.taxes.getCodesArray(),
iva_code: proformaItem.taxes.iva.match(
(iva) => iva.code,
() => ""
),
iva_percentage: proformaItem.taxes.iva.match(
(iva) => iva.percentage.toObjectString(),
() => ({ value: "", scale: "" })
),
iva_amount: allAmounts.ivaAmount.toObjectString(),
rec_code: proformaItem.taxes.rec.match(
(rec) => rec.code,
() => ""
),
rec_percentage: proformaItem.taxes.rec.match(
(rec) => rec.percentage.toObjectString(),
() => ({ value: "", scale: "" })
),
rec_amount: allAmounts.recAmount.toObjectString(),
retention_code: proformaItem.taxes.retention.match(
(retention) => retention.code,
() => ""
),
retention_percentage: proformaItem.taxes.retention.match(
(retention) => retention.percentage.toObjectString(),
() => ({ value: "", scale: "" })
),
retention_amount: allAmounts.retentionAmount.toObjectString(),
taxes_amount: allAmounts.taxesAmount.toObjectString(),
total_amount: allAmounts.totalAmount.toObjectString(),

View File

@ -2,7 +2,7 @@ import { Presenter } from "@erp/core/api";
import { toEmptyString } from "@repo/rdx-ddd";
import type { GetProformaByIdResponseDTO } from "../../../../../common/dto";
import type { CustomerInvoice } from "../../../../domain";
import { type CustomerInvoice, InvoiceAmount } from "../../../../domain";
import type { ProformaItemsFullPresenter } from "./proforma-items.full.presenter";
import type { ProformaRecipientFullPresenter } from "./proforma-recipient.full.presenter";
@ -21,7 +21,7 @@ export class ProformaFullPresenter extends Presenter<CustomerInvoice, GetProform
const recipient = recipientPresenter.toOutput(proforma);
const items = itemsPresenter.toOutput(proforma.items);
const allAmounts = proforma.getAllAmounts();
const allAmounts = proforma.calculateAllAmounts();
const payment = proforma.paymentMethod.match(
(payment) => {
@ -34,11 +34,49 @@ export class ProformaFullPresenter extends Presenter<CustomerInvoice, GetProform
() => undefined
);
const invoiceTaxes = proforma.getTaxes().map((taxItem) => {
let totalIvaAmount = InvoiceAmount.zero(proforma.currencyCode.code);
let totalRecAmount = InvoiceAmount.zero(proforma.currencyCode.code);
let totalRetentionAmount = InvoiceAmount.zero(proforma.currencyCode.code);
const invoiceTaxes = proforma.getTaxes().map((taxGroup) => {
const { ivaAmount, recAmount, retentionAmount, totalAmount } = taxGroup.calculateAmounts();
totalIvaAmount = totalIvaAmount.add(ivaAmount);
totalRecAmount = totalRecAmount.add(recAmount);
totalRetentionAmount = totalRetentionAmount.add(retentionAmount);
return {
tax_code: taxItem.tax.code,
taxable_amount: taxItem.taxableAmount.toObjectString(),
taxes_amount: taxItem.taxesAmount.toObjectString(),
taxable_amount: taxGroup.taxableAmount.toObjectString(),
iva_code: taxGroup.iva.code,
iva_percentage: taxGroup.iva.percentage.toObjectString(),
iva_amount: ivaAmount.toObjectString(),
rec_code: taxGroup.rec.match(
(rec) => rec.code,
() => ""
),
rec_percentage: taxGroup.rec.match(
(rec) => rec.percentage.toObjectString(),
() => ({ value: "", scale: "" })
),
rec_amount: recAmount.toObjectString(),
retention_code: taxGroup.retention.match(
(retention) => retention.code,
() => ""
),
retention_percentage: taxGroup.retention.match(
(retention) => retention.percentage.toObjectString(),
() => ({ value: "", scale: "" })
),
retention_amount: retentionAmount.toObjectString(),
taxes_amount: totalAmount.toObjectString(),
};
});
@ -72,9 +110,14 @@ export class ProformaFullPresenter extends Presenter<CustomerInvoice, GetProform
items_discount_amount: allAmounts.itemDiscountAmount.toObjectString(),
discount_percentage: proforma.discountPercentage.toObjectString(),
discount_amount: allAmounts.headerDiscountAmount.toObjectString(),
discount_amount: allAmounts.globalDiscountAmount.toObjectString(),
taxable_amount: allAmounts.taxableAmount.toObjectString(),
iva_amount: totalIvaAmount.toObjectString(),
rec_amount: totalRecAmount.toObjectString(),
retention_amount: totalRetentionAmount.toObjectString(),
taxes_amount: allAmounts.taxesAmount.toObjectString(),
total_amount: allAmounts.totalAmount.toObjectString(),

View File

@ -39,12 +39,6 @@ export class IssuedInvoiceListPresenter extends Presenter {
language_code: invoice.languageCode.code,
currency_code: invoice.currencyCode.code,
taxes: invoice.taxes.map((t) => ({
tax_code: t.tax_code,
taxable_amount: t.taxable_amount.toObjectString(),
taxes_amount: t.taxes_amount.toObjectString(),
})),
subtotal_amount: invoice.subtotalAmount.toObjectString(),
discount_percentage: invoice.discountPercentage.toObjectString(),
discount_amount: invoice.discountAmount.toObjectString(),

View File

@ -30,12 +30,6 @@ export class ProformaListPresenter extends Presenter {
language_code: proforma.languageCode.code,
currency_code: proforma.currencyCode.code,
taxes: proforma.taxes.map((t) => ({
tax_code: t.tax_code,
taxable_amount: t.taxable_amount.toObjectString(),
taxes_amount: t.taxes_amount.toObjectString(),
})),
subtotal_amount: proforma.subtotalAmount.toObjectString(),
discount_percentage: proforma.discountPercentage.toObjectString(),
discount_amount: proforma.discountAmount.toObjectString(),

View File

@ -1,4 +1,9 @@
import { type JsonTaxCatalogProvider, MoneyDTOHelper, SpainTaxCatalogProvider } from "@erp/core";
import {
type JsonTaxCatalogProvider,
MoneyDTOHelper,
PercentageDTOHelper,
SpainTaxCatalogProvider,
} from "@erp/core";
import { type IPresenterOutputParams, Presenter } from "@erp/core/api";
import type { GetIssuedInvoiceByIdResponseDTO } from "@erp/customer-invoices/common";
import type { ArrayElement } from "@repo/rdx-utils";
@ -16,17 +21,23 @@ export class IssuedInvoiceTaxesReportPresenter extends Presenter<IssuedInvoiceTa
minimumFractionDigits: 0,
};
const taxCatalogItem = this._taxCatalog.findByCode(taxItem.tax_code);
const taxName = taxCatalogItem.match(
(item) => item.name,
() => taxItem.tax_code // fallback
);
//const taxCatalogItem = this._taxCatalog.findByCode(taxItem.tax_code);
return {
tax_code: taxItem.tax_code,
tax_name: taxName,
taxable_amount: MoneyDTOHelper.format(taxItem.taxable_amount, this._locale, moneyOptions),
iva_code: taxItem.iva_code,
iva_percentage: PercentageDTOHelper.format(taxItem.iva_percentage, this._locale),
iva_amount: MoneyDTOHelper.format(taxItem.iva_amount, this._locale, moneyOptions),
rec_code: taxItem.rec_code,
rec_percentage: PercentageDTOHelper.format(taxItem.rec_percentage, this._locale),
rec_amount: MoneyDTOHelper.format(taxItem.rec_amount, this._locale, moneyOptions),
retention_code: taxItem.retention_code,
retention_percentage: PercentageDTOHelper.format(taxItem.retention_percentage, this._locale),
retention_amount: MoneyDTOHelper.format(taxItem.rec_amount, this._locale, moneyOptions),
taxes_amount: MoneyDTOHelper.format(taxItem.taxes_amount, this._locale, moneyOptions),
};
}

View File

@ -1,4 +1,9 @@
import { type JsonTaxCatalogProvider, MoneyDTOHelper, SpainTaxCatalogProvider } from "@erp/core";
import {
type JsonTaxCatalogProvider,
MoneyDTOHelper,
PercentageDTOHelper,
SpainTaxCatalogProvider,
} from "@erp/core";
import { type IPresenterOutputParams, Presenter } from "@erp/core/api";
import type { GetProformaByIdResponseDTO } from "@erp/customer-invoices/common";
import type { ArrayElement } from "@repo/rdx-utils";
@ -16,17 +21,23 @@ export class ProformaTaxesReportPresenter extends Presenter<ProformaTaxesDTO, un
minimumFractionDigits: 0,
};
const taxCatalogItem = this._taxCatalog.findByCode(taxItem.tax_code);
const taxName = taxCatalogItem.match(
(item) => item.name,
() => taxItem.tax_code // fallback
);
//const taxCatalogItem = this._taxCatalog.findByCode(taxItem.tax_code);
return {
tax_code: taxItem.tax_code,
tax_name: taxName,
taxable_amount: MoneyDTOHelper.format(taxItem.taxable_amount, this._locale, moneyOptions),
iva_code: taxItem.iva_code,
iva_percentage: PercentageDTOHelper.format(taxItem.iva_percentage, this._locale),
iva_amount: MoneyDTOHelper.format(taxItem.iva_amount, this._locale, moneyOptions),
rec_code: taxItem.rec_code,
rec_percentage: PercentageDTOHelper.format(taxItem.rec_percentage, this._locale),
rec_amount: MoneyDTOHelper.format(taxItem.rec_amount, this._locale, moneyOptions),
retention_code: taxItem.retention_code,
retention_percentage: PercentageDTOHelper.format(taxItem.retention_percentage, this._locale),
retention_amount: MoneyDTOHelper.format(taxItem.rec_amount, this._locale, moneyOptions),
taxes_amount: MoneyDTOHelper.format(taxItem.taxes_amount, this._locale, moneyOptions),
};
}

View File

@ -227,7 +227,7 @@ export class CreateCustomerInvoicePropsMapper {
description: description!,
quantity: quantity!,
unitAmount: unitAmount!,
discountPercentage: discountPercentage!,
itemDiscountPercentage: discountPercentage!,
taxes: taxes,
};

View File

@ -8,20 +8,17 @@ import {
type UniqueID,
type UtcDate,
} from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import { Collection, type Maybe, Result } from "@repo/rdx-utils";
import {
CustomerInvoiceItems,
type InvoicePaymentMethod,
type InvoiceTaxTotal,
type VerifactuRecord,
} from "../entities";
import { CustomerInvoiceItems, type InvoicePaymentMethod, type VerifactuRecord } from "../entities";
import {
type CustomerInvoiceNumber,
type CustomerInvoiceSerie,
type CustomerInvoiceStatus,
InvoiceAmount,
type InvoiceRecipient,
type InvoiceTaxGroup,
type ItemAmount,
} from "../value-objects";
export interface CustomerInvoiceProps {
@ -69,7 +66,7 @@ export interface ICustomerInvoice {
hasRecipient: boolean;
hasPaymentMethod: boolean;
getTaxes(): InvoiceTaxTotal[];
getTaxes(): Collection<InvoiceTaxGroup>;
getProps(): CustomerInvoiceProps;
}
@ -86,25 +83,10 @@ export class CustomerInvoice
CustomerInvoiceItems.create({
languageCode: props.languageCode,
currencyCode: props.currencyCode,
globalDiscountPercentage: props.discountPercentage,
});
}
getHeaderDiscountAmount(): InvoiceAmount {
throw new Error("Method not implemented.");
}
getTaxableAmount(): InvoiceAmount {
throw new Error("Method not implemented.");
}
getTaxesAmount(): InvoiceAmount {
throw new Error("Method not implemented.");
}
getTotalAmount(): InvoiceAmount {
throw new Error("Method not implemented.");
}
static create(props: CustomerInvoiceProps, id?: UniqueID): Result<CustomerInvoice, Error> {
const customerInvoice = new CustomerInvoice(props, id);
@ -127,25 +109,7 @@ export class CustomerInvoice
return Result.ok(customerInvoice);
}
public update(partialInvoice: CustomerInvoicePatchProps): Result<CustomerInvoice, Error> {
const { items, ...rest } = partialInvoice;
const updatedProps = {
...this.props,
...rest,
} as CustomerInvoiceProps;
/*if (partialAddress) {
const updatedAddressOrError = this.address.update(partialAddress);
if (updatedAddressOrError.isFailure) {
return Result.fail(updatedAddressOrError.error);
}
updatedProps.address = updatedAddressOrError.data;
}*/
return CustomerInvoice.create(updatedProps, this.id);
}
// Getters
public get companyId(): UniqueID {
return this.props.companyId;
@ -236,65 +200,123 @@ export class CustomerInvoice
return this.paymentMethod.isSome();
}
/* CALCULOS INTERNOS */
private _getSubtotalAmount(): InvoiceAmount {
const itemsSubtotal = this.items.getSubtotalAmount().convertScale(2);
// Helpers
/**
* @summary Convierte un ItemAmount a InvoiceAmount (mantiene moneda y escala homogénea).
*/
private _toInvoiceAmount(itemAmount: ItemAmount): InvoiceAmount {
return InvoiceAmount.create({
value: itemsSubtotal.value,
value: itemAmount.convertScale(InvoiceAmount.DEFAULT_SCALE).value,
currency_code: this.currencyCode.code,
}).data;
}
private _getItemsDiscountAmount(): InvoiceAmount {
const itemsDiscountAmount = this.items.getDiscountAmount().convertScale(2);
// Cálculos
return InvoiceAmount.create({
value: itemsDiscountAmount.value,
currency_code: this.currencyCode.code,
}).data;
/**
* @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);
return {
subtotalAmount,
itemDiscountAmount,
globalDiscountAmount,
totalDiscountAmount,
taxableAmount,
taxesAmount,
totalAmount,
} as const;
}
private _getHeaderDiscountAmount(
subtotalAmount: InvoiceAmount,
itemsDiscountAmount: InvoiceAmount
): InvoiceAmount {
return subtotalAmount.subtract(itemsDiscountAmount).percentage(this.discountPercentage);
// Métodos públicos
public getProps(): CustomerInvoiceProps {
return this.props;
}
private _getTaxableAmount(
subtotalAmount: InvoiceAmount,
itemsDiscountAmount: InvoiceAmount,
headerDiscountAmount: InvoiceAmount
): InvoiceAmount {
return subtotalAmount.subtract(itemsDiscountAmount).subtract(headerDiscountAmount);
public update(partialInvoice: CustomerInvoicePatchProps): Result<CustomerInvoice, Error> {
const { items, ...rest } = partialInvoice;
const updatedProps = {
...this.props,
...rest,
} as CustomerInvoiceProps;
/*if (partialAddress) {
const updatedAddressOrError = this.address.update(partialAddress);
if (updatedAddressOrError.isFailure) {
return Result.fail(updatedAddressOrError.error);
}
// total impuestos suma(iva + rec + retenciones)
private _getTaxesAmount(): InvoiceAmount {
const { iva, rec, retention } = this.items.getAggregatedTaxesByType();
const total = iva.add(rec).add(retention);
return InvoiceAmount.create({
value: total.convertScale(2).value,
currency_code: this.currencyCode.code,
}).data;
updatedProps.address = updatedAddressOrError.data;
}*/
return CustomerInvoice.create(updatedProps, this.id);
}
private _getTotalAmount(taxableAmount: InvoiceAmount, taxesAmount: InvoiceAmount): InvoiceAmount {
return taxableAmount.add(taxesAmount);
public getSubtotalAmount(): InvoiceAmount {
return this.calculateAllAmounts().subtotalAmount;
}
/** Totales expuestos */
public getItemDiscountAmount(): InvoiceAmount {
return this.calculateAllAmounts().itemDiscountAmount;
}
public getTaxes(): InvoiceTaxTotal[] {
const map = this.items.getAggregatedTaxesByCode();
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 Devuelve la agrupación de impuestos útil para poblar `customer_invoice_taxes`.
*/
public getTaxesGroupedByCode() {
return this.items.groupTaxesByCode();
}
public getTaxes(): Collection<InvoiceTaxGroup> {
const map = this.items.groupTaxesByCode();
const currency = this.currencyCode.code;
const result: InvoiceTaxTotal[] = [];
const result = new Collection<InvoiceTaxGroup>([]);
for (const [tax_code, entry] of map.entries()) {
result.push({
const value: InvoiceTaxGroup = {
ta,
};
result.push(value);
/*result.push({
tax: entry.tax,
taxableAmount: InvoiceAmount.create({
value: entry.taxable.convertScale(2).value,
@ -304,37 +326,9 @@ export class CustomerInvoice
value: entry.total.convertScale(2).value,
currency_code: currency,
}).data,
});
});*/
}
return result;
}
public getAllAmounts() {
const subtotalAmount = this._getSubtotalAmount(); // Sin IVA ni dtos de línea
const itemDiscountAmount = this._getItemsDiscountAmount(); // Suma de los Importes de descuentos de linea
const headerDiscountAmount = this._getHeaderDiscountAmount(subtotalAmount, itemDiscountAmount); // Importe de descuento de cabecera
const taxableAmount = this._getTaxableAmount(
subtotalAmount,
itemDiscountAmount,
headerDiscountAmount
); //
const taxesAmount = this._getTaxesAmount();
const totalAmount = this._getTotalAmount(taxableAmount, taxesAmount);
return {
subtotalAmount,
itemDiscountAmount,
headerDiscountAmount,
taxableAmount,
taxesAmount,
totalAmount,
};
}
public getProps(): CustomerInvoiceProps {
return this.props;
return new Collection(result);
}
}

View File

@ -9,12 +9,30 @@ import {
} from "../../value-objects";
import type { ItemTaxGroup } from "../../value-objects/item-tax-group";
/**
*
* Entidad de línea de factura.
*
* Modela:
* - subtotal = cantidad × precio
* - descuento de línea
* - descuento global (prorrateado proporcionalmente desde cabecera)
* - base imponible
* - impuestos
* - total final
*
* Esta entidad es inmutable en comportamiento: todos los importes se calculan
* en tiempo real a partir de las propiedades (cantidad, precio y porcentajes).
*
*/
export interface CustomerInvoiceItemProps {
description: Maybe<CustomerInvoiceItemDescription>;
quantity: Maybe<ItemQuantity>; // Cantidad de unidades
unitAmount: Maybe<ItemAmount>; // Precio unitario en la moneda de la factura
discountPercentage: Maybe<ItemDiscount>; // % descuento
itemDiscountPercentage: Maybe<ItemDiscount>; // % descuento
globalDiscountPercentage: Maybe<ItemDiscount>; // % descuento de la cabecera
taxes: ItemTaxGroup;
@ -22,33 +40,7 @@ export interface CustomerInvoiceItemProps {
currencyCode: CurrencyCode;
}
export interface ICustomerInvoiceItem {
isValued: boolean;
description: Maybe<CustomerInvoiceItemDescription>;
quantity: Maybe<ItemQuantity>; // Cantidad de unidades
unitAmount: Maybe<ItemAmount>; // Precio unitario en la moneda de la factura
discountPercentage: Maybe<ItemDiscount>; // % descuento
taxes: ItemTaxGroup;
languageCode: LanguageCode;
currencyCode: CurrencyCode;
getSubtotalAmount(): ItemAmount;
getDiscountAmount(): ItemAmount;
getTaxableAmount(): ItemAmount;
getTaxesAmount(): ItemAmount;
getTotalAmount(): ItemAmount;
}
export class CustomerInvoiceItem
extends DomainEntity<CustomerInvoiceItemProps>
implements ICustomerInvoiceItem
{
export class CustomerInvoiceItem extends DomainEntity<CustomerInvoiceItemProps> {
protected _isValued!: boolean;
public static create(
@ -70,6 +62,8 @@ export class CustomerInvoiceItem
this._isValued = this.quantity.isSome() || this.unitAmount.isSome();
}
// Getters
get isValued(): boolean {
return this._isValued;
}
@ -77,24 +71,34 @@ export class CustomerInvoiceItem
get description() {
return this.props.description;
}
get quantity() {
return this.props.quantity;
}
get unitAmount() {
return this.props.unitAmount;
}
get discountPercentage() {
return this.props.discountPercentage;
get itemDiscountPercentage() {
return this.props.itemDiscountPercentage;
}
get globalDiscountPercentage() {
return this.props.globalDiscountPercentage;
}
get taxes() {
return this.props.taxes;
}
get languageCode() {
return this.props.languageCode;
}
get currencyCode() {
return this.props.currencyCode;
}
get taxes() {
return this.props.taxes;
}
getProps(): CustomerInvoiceItemProps {
return this.props;
@ -104,65 +108,12 @@ export class CustomerInvoiceItem
return this.getProps();
}
/**
* @private
* @summary Calcula el importe de descuento a partir del subtotal y el porcentaje.
* @param subtotalAmount - Importe subtotal.
* @returns El importe de descuento calculado.
*/
private _getDiscountAmount(subtotalAmount: ItemAmount): ItemAmount {
const discount = this.discountPercentage.match(
(discount) => discount,
() => ItemDiscount.zero()
);
return subtotalAmount.percentage(discount);
}
// Ayudantes
/**
* @private
* @summary Calcula el importe imponible restando el descuento al subtotal.
* @param subtotalAmount - Importe subtotal.
* @param discountAmount - Importe de descuento.
* @returns El importe imponible resultante.
* @summary Helper puro para calcular el subtotal.
*/
private _getTaxableAmount(subtotalAmount: ItemAmount, discountAmount: ItemAmount): ItemAmount {
return subtotalAmount.subtract(discountAmount);
}
/* importes individuales: iva / rec / ret */
private _getIndividualTaxAmounts(taxableAmount: ItemAmount) {
return this.props.taxes.calculateAmounts(taxableAmount);
}
/**
* @private
* @summary Calcula el importe total de impuestos sobre la base imponible.
* @param taxableAmount - Importe imponible.
* @returns El importe de impuestos calculado.
*/
private _getTaxesAmount(taxableAmount: ItemAmount): ItemAmount {
const { ivaAmount, recAmount, retentionAmount } = this._getIndividualTaxAmounts(taxableAmount);
return ivaAmount.add(recAmount).add(retentionAmount); // retención ya es negativa
}
/**
* @private
* @summary Calcula el importe total sumando base imponible e impuestos.
* @param taxableAmount - Importe imponible.
* @param taxesAmount - Importe de impuestos.
* @returns El importe total del ítem.
*/
private _getTotalAmount(taxableAmount: ItemAmount, taxesAmount: ItemAmount): ItemAmount {
return taxableAmount.add(taxesAmount);
}
/**
* @summary Calcula el subtotal del ítem (cantidad × importe unitario).
* @returns Un `ItemAmount` con el subtotal del ítem.
* @remarks
* Si la cantidad o el importe unitario no están definidos, se asumen valores cero.
*/
public getSubtotalAmount(): ItemAmount {
private _calculateSubtotalAmount(): ItemAmount {
const qty = this.quantity.match(
(quantity) => quantity,
() => ItemQuantity.zero()
@ -175,70 +126,137 @@ export class CustomerInvoiceItem
}
/**
* @summary Calcula el importe total de descuento del ítem.
* @returns Un `ItemAmount` con el importe descontado.
* @summary Helper puro para calcular el descuento de línea.
*/
public getDiscountAmount(): ItemAmount {
return this._getDiscountAmount(this.getSubtotalAmount());
private _calculateItemDiscountAmount(subtotal: ItemAmount): ItemAmount {
const discountPercentage = this.props.itemDiscountPercentage.match(
(discount) => discount,
() => ItemDiscount.zero()
);
return subtotal.percentage(discountPercentage);
}
/**
* @summary Calcula el importe imponible (subtotal descuento).
* @returns Un `ItemAmount` con la base imponible del ítem.
* @summary Helper puro para calcular el descuento global.
*/
public getTaxableAmount(): ItemAmount {
return this._getTaxableAmount(this.getSubtotalAmount(), this.getDiscountAmount());
}
private _calculateGlobalDiscountAmount(
subtotalAmount: ItemAmount,
discountAmount: ItemAmount
): ItemAmount {
const amountAfterLineDiscount = subtotalAmount.subtract(discountAmount);
/* importes individuales: iva / rec / ret */
public getIndividualTaxAmounts() {
return this._getIndividualTaxAmounts(this.getTaxableAmount());
const globalDiscount = this.props.globalDiscountPercentage.match(
(discount) => discount,
() => ItemDiscount.zero()
);
return amountAfterLineDiscount.percentage(globalDiscount);
}
/**
* @summary Calcula el importe total de impuestos aplicados al ítem.
* @returns Un `ItemAmount` con el total de impuestos.
* @summary Helper puro para calcular la suma de descuentos.
*/
public getTaxesAmount(): ItemAmount {
return this._getTaxesAmount(this.getTaxableAmount());
private _calculateTotalDiscountAmount(
itemDiscountAmount: ItemAmount,
globalDiscountAmount: ItemAmount
) {
return itemDiscountAmount.add(globalDiscountAmount);
}
/**
* @summary Calcula el importe total final del ítem (base imponible + impuestos).
* @returns Un `ItemAmount` con el importe total.
* @summary Helper puro para calcular impuestos individuales.
*/
public getTotalAmount(): ItemAmount {
const taxableAmount = this.getTaxableAmount();
const taxesAmount = this._getTaxesAmount(taxableAmount);
return this._getTotalAmount(taxableAmount, taxesAmount);
private _calculateIndividualTaxes(taxable: ItemAmount) {
return this.taxes.calculateAmounts(taxable);
}
// Cálculos
/**
* @summary Devuelve todos los importes calculados del ítem en un único objeto.
* @returns Un objeto con las propiedades:
* - `subtotalAmount`
* - `discountAmount`
* - `taxableAmount`
* - `taxesAmount`
* - `totalAmount`
* @remarks
* Este método es útil para mostrar todos los cálculos en la interfaz de usuario
* o serializar el ítem con sus valores calculados.
* @summary Cálculo centralizado de todos los valores intermedios.
* @returns Devuelve un objeto inmutable con todos los valores necesarios:
* - subtotal
* - itemDiscount
* - globalDiscount
* - totalDiscount
* - taxableAmount
* - ivaAmount
* - recAmount
* - retentionAmount
* - taxesAmount
* - totalAmount
*
*/
public getAllAmounts() {
const subtotalAmount = this.getSubtotalAmount();
const discountAmount = this._getDiscountAmount(subtotalAmount);
const taxableAmount = this._getTaxableAmount(subtotalAmount, discountAmount);
const taxesAmount = this._getTaxesAmount(taxableAmount);
const totalAmount = this._getTotalAmount(taxableAmount, taxesAmount);
public calculateAllAmounts() {
const subtotalAmount = this._calculateSubtotalAmount();
const itemDiscountAmount = this._calculateItemDiscountAmount(subtotalAmount);
const globalDiscountAmount = this._calculateGlobalDiscountAmount(
subtotalAmount,
itemDiscountAmount
);
const totalDiscountAmount = this._calculateTotalDiscountAmount(
itemDiscountAmount,
globalDiscountAmount
);
const taxableAmount = subtotalAmount.subtract(totalDiscountAmount);
const { ivaAmount, recAmount, retentionAmount } = this._calculateIndividualTaxes(taxableAmount);
const taxesAmount = ivaAmount.add(recAmount).add(retentionAmount);
const totalAmount = taxableAmount.add(taxesAmount);
return {
subtotalAmount,
discountAmount,
itemDiscountAmount,
globalDiscountAmount,
totalDiscountAmount,
taxableAmount,
ivaAmount,
recAmount,
retentionAmount,
taxesAmount,
totalAmount,
};
} 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 getIndividualTaxAmounts() {
const { ivaAmount, recAmount, retentionAmount } = this.calculateAllAmounts();
return { ivaAmount, recAmount, retentionAmount };
}
public getTaxesAmount(): ItemAmount {
return this.calculateAllAmounts().taxesAmount;
}
public getTotalAmount(): ItemAmount {
return this.calculateAllAmounts().totalAmount;
}
}

View File

@ -1,8 +1,8 @@
import type { Tax } from "@erp/core/api";
import type { CurrencyCode, LanguageCode } from "@repo/rdx-ddd";
import type { CurrencyCode, LanguageCode, Percentage } from "@repo/rdx-ddd";
import { Collection } from "@repo/rdx-utils";
import { ItemAmount } from "../../value-objects";
import { ItemAmount, ItemDiscount } from "../../value-objects";
import type { CustomerInvoiceItem } from "./customer-invoice-item";
@ -10,101 +10,38 @@ export interface CustomerInvoiceItemsProps {
items?: CustomerInvoiceItem[];
languageCode: LanguageCode;
currencyCode: CurrencyCode;
globalDiscountPercentage: Percentage;
}
export class CustomerInvoiceItems extends Collection<CustomerInvoiceItem> {
private _languageCode!: LanguageCode;
private _currencyCode!: CurrencyCode;
private _globalDiscountPercentage!: Percentage;
constructor(props: CustomerInvoiceItemsProps) {
super(props.items ?? []);
this._languageCode = props.languageCode;
this._currencyCode = props.currencyCode;
this._globalDiscountPercentage = props.globalDiscountPercentage;
}
public static create(props: CustomerInvoiceItemsProps): CustomerInvoiceItems {
return new CustomerInvoiceItems(props);
}
/**
* @summary Añade un nuevo ítem a la colección.
* @param item - El ítem de factura a añadir.
* @returns `true` si el ítem fue añadido correctamente; `false` si fue rechazado.
* @remarks
* Sólo se aceptan ítems cuyo `LanguageCode` y `CurrencyCode` coincidan con
* los de la colección. Si no coinciden, el método devuelve `false` sin modificar
* la colección.
*/
add(item: CustomerInvoiceItem): 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.
if (
!(
this._languageCode.equals(item.languageCode) && this._currencyCode.equals(item.currencyCode)
)
) {
return false;
}
return super.add(item);
}
// Helpers
/**
* @summary Calcula el subtotal de todos los ítems sin descuentos ni impuestos.
* @returns Un `ItemAmount` con el subtotal total de la colección en su moneda.
*/
public getSubtotalAmount(): ItemAmount {
private _sumAmounts(selector: (item: CustomerInvoiceItem) => ItemAmount): ItemAmount {
return this.getAll().reduce(
(total, item) => total.add(item.getSubtotalAmount()),
(acc, item) => acc.add(selector(item)),
ItemAmount.zero(this._currencyCode.code)
);
}
/**
* @summary Calcula el importe total de descuentos aplicados a todos los ítems.
* @returns Un `ItemAmount` con el importe total de descuentos.
* @summary Helper puro para sumar impuestos individuales por tipo.
*/
public getDiscountAmount(): ItemAmount {
return this.getAll().reduce(
(total, item) => total.add(item.getDiscountAmount()),
ItemAmount.zero(this._currencyCode.code)
);
}
/**
* @summary Calcula el importe imponible total (base antes de impuestos).
* @returns Un `ItemAmount` con el total imponible.
*/
public getTaxableAmount(): ItemAmount {
return this.getAll().reduce(
(total, item) => total.add(item.getTaxableAmount()),
ItemAmount.zero(this._currencyCode.code)
);
}
/**
* @summary Calcula el importe total de impuestos de todos los ítems.
* @returns Un `ItemAmount` con la suma de todos los impuestos aplicados.
*/
public getTaxesAmount(): ItemAmount {
return this.getAll().reduce(
(total, item) => total.add(item.getTaxesAmount()),
ItemAmount.zero(this._currencyCode.code)
);
}
/**
* @summary Calcula el importe total final de todos los ítems (subtotal -descuentos + impuestos).
* @returns Un `ItemAmount` con el total global de la colección.
*/
public getTotalAmount(): ItemAmount {
return this.getAll().reduce(
(total, item) => total.add(item.getTotalAmount()),
ItemAmount.zero(this._currencyCode.code)
);
}
/* totales de iva/rec/ret a nivel factura */
public getAggregatedTaxesByType() {
private _calculateIndividualTaxes() {
let iva = ItemAmount.zero(this._currencyCode.code);
let rec = ItemAmount.zero(this._currencyCode.code);
let retention = ItemAmount.zero(this._currencyCode.code);
@ -120,21 +57,157 @@ export class CustomerInvoiceItems extends Collection<CustomerInvoiceItem> {
return { iva, rec, retention };
}
/* agrupación por código fiscal → usado para customer_invoice_taxes */
public getAggregatedTaxesByCode() {
//
/**
* @summary Añade un nuevo ítem a la colección.
* @param item - El ítem de factura a añadir.
* @returns `true` si el ítem fue añadido correctamente; `false` si fue rechazado.
* @remarks
* Sólo se aceptan ítems cuyo `LanguageCode` y `CurrencyCode` coincidan con
* los de la colección. Si no coinciden, el método devuelve `false` sin modificar
* la colección.
*/
add(item: CustomerInvoiceItem): 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.
if (
!(
this._languageCode.equals(item.languageCode) &&
this._currencyCode.equals(item.currencyCode) &&
this._globalDiscountPercentage.equals(
item.globalDiscountPercentage.match(
(v) => v,
() => ItemDiscount.zero()
)
)
)
) {
return false;
}
return super.add(item);
}
// Cálculos
/**
* @summary Orquestador central del cálculo agregado de la colección.
* @remarks
* Delega en los ítems individuales (DDD correcto) pero evita múltiples recorridos.
*/
public calculateAllAmounts() {
let subtotalAmount = ItemAmount.zero(this._currencyCode.code);
let itemDiscountAmount = ItemAmount.zero(this._currencyCode.code);
let globalDiscountAmount = ItemAmount.zero(this._currencyCode.code);
let totalDiscountAmount = ItemAmount.zero(this._currencyCode.code);
let taxableAmount = ItemAmount.zero(this._currencyCode.code);
let ivaAmount = ItemAmount.zero(this._currencyCode.code);
let recAmount = ItemAmount.zero(this._currencyCode.code);
let retentionAmount = ItemAmount.zero(this._currencyCode.code);
let taxesAmount = ItemAmount.zero(this._currencyCode.code);
let totalAmount = ItemAmount.zero(this._currencyCode.code);
for (const item of this.getAll()) {
const amounts = item.calculateAllAmounts();
// Subtotales
subtotalAmount = subtotalAmount.add(amounts.subtotalAmount);
// Descuentos
itemDiscountAmount = itemDiscountAmount.add(amounts.itemDiscountAmount);
globalDiscountAmount = globalDiscountAmount.add(amounts.globalDiscountAmount);
totalDiscountAmount = totalDiscountAmount.add(amounts.totalDiscountAmount);
// Base imponible
taxableAmount = taxableAmount.add(amounts.taxableAmount);
// Impuestos individuales
ivaAmount = ivaAmount.add(amounts.ivaAmount);
recAmount = recAmount.add(amounts.recAmount);
retentionAmount = retentionAmount.add(amounts.retentionAmount);
// Total impuestos del ítem
taxesAmount = taxesAmount.add(amounts.taxesAmount);
// Total final del ítem
totalAmount = totalAmount.add(amounts.totalAmount);
}
return {
subtotalAmount,
itemDiscountAmount,
globalDiscountAmount,
totalDiscountAmount,
taxableAmount,
ivaAmount,
recAmount,
retentionAmount,
taxesAmount,
totalAmount,
} 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 Agrupa bases e importes por código fiscal.
* @remarks
* Este método se usa para poblar la tabla `customer_invoice_taxes`.
*/
public groupTaxesByCode() {
const map = new Map<string, { tax: Tax; taxable: ItemAmount; total: ItemAmount }>();
for (const item of this.getAll()) {
const taxable = item.getTaxableAmount();
const { ivaAmount, recAmount, retentionAmount } = item.getIndividualTaxAmounts();
const amounts = item.calculateAllAmounts();
const taxable = amounts.taxableAmount;
const { ivaAmount, recAmount, retentionAmount } = amounts;
/* ----------------------------- IVA ----------------------------- */
item.taxes.iva.match(
(iva) => {
const prev = map.get(iva.code) ?? {
const key = iva.code;
const prev = map.get(key) ?? {
tax: iva,
taxable: ItemAmount.zero(taxable.currencyCode),
total: ItemAmount.zero(taxable.currencyCode),
};
map.set(iva.code, {
map.set(key, {
tax: iva,
taxable: prev.taxable.add(taxable),
total: prev.total.add(ivaAmount),
@ -145,13 +218,17 @@ export class CustomerInvoiceItems extends Collection<CustomerInvoiceItem> {
}
);
/* ----------------------------- REC ----------------------------- */
item.taxes.rec.match(
(rec) => {
const prev = map.get(rec.code) ?? {
const key = rec.code;
const prev = map.get(key) ?? {
tax: rec,
taxable: ItemAmount.zero(taxable.currencyCode),
total: ItemAmount.zero(taxable.currencyCode),
};
map.set(rec.code, {
map.set(key, {
tax: rec,
taxable: prev.taxable.add(taxable),
total: prev.total.add(recAmount),
@ -162,13 +239,17 @@ export class CustomerInvoiceItems extends Collection<CustomerInvoiceItem> {
}
);
/* -------------------------- RETENCIÓN -------------------------- */
item.taxes.retention.match(
(retention) => {
const prev = map.get(retention.code) ?? {
const key = retention.code;
const prev = map.get(key) ?? {
tax: retention,
taxable: ItemAmount.zero(taxable.currencyCode),
total: ItemAmount.zero(taxable.currencyCode),
};
map.set(retention.code, {
map.set(key, {
tax: retention,
taxable: prev.taxable.add(taxable),
total: prev.total.add(retentionAmount),

View File

@ -1,19 +1,15 @@
import { type Tax, Taxes } from "@erp/core/api";
import { Collection } from "@repo/rdx-utils";
import { InvoiceAmount } from "../../value-objects";
import { InvoiceAmount, type InvoiceTaxGroup } from "../../value-objects";
export type InvoiceTaxTotal = {
tax: Tax;
taxableAmount: InvoiceAmount;
taxesAmount: InvoiceAmount;
};
export type InvoiceTaxTotal = {};
export class InvoiceTaxes extends Taxes {
constructor(items: Tax[] = [], totalItems: number | null = null) {
export class InvoiceTaxes extends Collection<InvoiceTaxGroup> {
constructor(items: InvoiceTaxGroup[] = [], totalItems: number | null = null) {
super(items, totalItems);
}
public getTaxesAmount(taxableAmount: InvoiceAmount): InvoiceAmount {
public getIVAAmount(): InvoiceAmount {
return this.getAll().reduce(
(total, tax) => total.add(taxableAmount.percentage(tax.percentage)),
InvoiceAmount.zero(taxableAmount.currencyCode)

View File

@ -5,6 +5,7 @@ export * from "./customer-invoice-serie";
export * from "./customer-invoice-status";
export * from "./invoice-amount";
export * from "./invoice-recipient";
export * from "./invoice-tax-group";
export * from "./item-amount";
export * from "./item-discount";
export * from "./item-quantity";

View File

@ -0,0 +1,142 @@
import type { Tax } from "@erp/core/api";
import { ValueObject } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import { InvoiceAmount } from "./invoice-amount";
import type { ItemTaxGroup } from "./item-tax-group";
export interface 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: ItemTaxGroup, 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

@ -105,6 +105,14 @@ export class CustomerInvoiceItemDomainMapper
errors
);
const globalDiscountPercentage = extractOrPushError(
ItemDiscount.create({
value: source.global_discount_percentage_value,
}),
`items[${index}].global_discount_percentage`,
errors
);
const iva = extractOrPushError(
maybeFromNullableVO(source.iva_code, (code) => Tax.createFromCode(code, this._taxCatalog)),
`items[${index}].iva_code`,
@ -133,7 +141,8 @@ export class CustomerInvoiceItemDomainMapper
description,
quantity,
unitAmount,
discountPercentage,
itemDiscountPercentage: discountPercentage,
globalDiscountPercentage,
taxes: ItemTaxGroup.create({
iva: iva!,
@ -171,7 +180,8 @@ export class CustomerInvoiceItemDomainMapper
description: attributes.description!,
quantity: attributes.quantity!,
unitAmount: attributes.unitAmount!,
discountPercentage: attributes.discountPercentage!,
itemDiscountPercentage: attributes.itemDiscountPercentage!,
globalDiscountPercentage: attributes.globalDiscountPercentage!,
taxes: attributes.taxes!,
},
attributes.itemId
@ -198,7 +208,8 @@ export class CustomerInvoiceItemDomainMapper
errors: ValidationErrorDetail[];
};
const allAmounts = source.getAllAmounts();
const allAmounts = source.calculateAllAmounts();
const taxesAmounts = source.taxes.calculateAmounts(allAmounts.taxableAmount);
return Result.ok({
item_id: source.id.toPrimitive(),
@ -218,29 +229,74 @@ export class CustomerInvoiceItemDomainMapper
subtotal_amount_value: allAmounts.subtotalAmount.value,
subtotal_amount_scale: allAmounts.subtotalAmount.scale,
//
discount_percentage_value: toNullable(
source.discountPercentage,
source.itemDiscountPercentage,
(v) => v.toPrimitive().value
),
discount_percentage_scale:
toNullable(source.discountPercentage, (v) => v.toPrimitive().scale) ??
toNullable(source.itemDiscountPercentage, (v) => v.toPrimitive().scale) ??
ItemDiscount.DEFAULT_SCALE,
discount_amount_value: allAmounts.discountAmount.value,
discount_amount_scale: allAmounts.discountAmount.scale,
discount_amount_value: allAmounts.itemDiscountAmount.value,
discount_amount_scale: allAmounts.itemDiscountAmount.scale,
//
global_discount_percentage_value: toNullable(
source.globalDiscountPercentage,
(v) => v.toPrimitive().value
),
global_discount_percentage_scale:
toNullable(source.globalDiscountPercentage, (v) => v.toPrimitive().scale) ??
ItemDiscount.DEFAULT_SCALE,
global_discount_amount_value: allAmounts.globalDiscountAmount.value,
global_discount_amount_scale: allAmounts.globalDiscountAmount.scale,
//
total_discount_amount_value: allAmounts.totalDiscountAmount.value,
total_discount_amount_scale: allAmounts.totalDiscountAmount.scale,
//
taxable_amount_value: allAmounts.taxableAmount.value,
taxable_amount_scale: allAmounts.taxableAmount.scale,
// IVA
iva_code: toNullable(source.taxes.iva, (v) => v.code),
iva_percentage_value: toNullable(source.taxes.iva, (v) => v.percentage.value),
iva_percentage_scale: toNullable(source.taxes.iva, (v) => v.percentage.scale) ?? 2,
iva_amount_value: taxesAmounts.ivaAmount.value,
iva_amount_scale: taxesAmounts.ivaAmount.scale,
// REC
rec_code: toNullable(source.taxes.rec, (v) => v.code),
rec_percentage_value: toNullable(source.taxes.rec, (v) => v.percentage.value),
rec_percentage_scale: toNullable(source.taxes.rec, (v) => v.percentage.scale) ?? 2,
rec_amount_value: taxesAmounts.recAmount.value,
rec_amount_scale: taxesAmounts.recAmount.scale,
// RET
retention_code: toNullable(source.taxes.retention, (v) => v.code),
retention_percentage_value: toNullable(source.taxes.retention, (v) => v.percentage.value),
retention_percentage_scale:
toNullable(source.taxes.retention, (v) => v.percentage.scale) ?? 2,
retention_amount_value: taxesAmounts.retentionAmount.value,
retention_amount_scale: taxesAmounts.retentionAmount.scale,
//
taxes_amount_value: allAmounts.taxesAmount.value,
taxes_amount_scale: allAmounts.taxesAmount.scale,
//
total_amount_value: allAmounts.totalAmount.value,
total_amount_scale: allAmounts.totalAmount.scale,
iva_code: toNullable(source.taxes.iva, (t) => t.code),
rec_code: toNullable(source.taxes.rec, (t) => t.code),
retention_code: toNullable(source.taxes.retention, (t) => t.code),
});
}
}

View File

@ -0,0 +1,150 @@
import type { JsonTaxCatalogProvider } from "@erp/core";
import { type MapperParamsType, SequelizeDomainMapper } from "@erp/core/api";
import { UniqueID, type ValidationErrorDetail, toNullable } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { CustomerInvoice, InvoiceTaxGroup } from "../../../domain";
import type {
CustomerInvoiceTaxCreationAttributes,
CustomerInvoiceTaxModel,
} from "../../sequelize";
/**
* Mapper para customer_invoice_taxes
*
* Domina estructuras:
* {
* tax: Tax
* taxableAmount: ItemAmount
* taxesAmount: ItemAmount
* }
*
* Cada fila = un impuesto agregado en toda la factura.
*/
export class CustomerInvoiceTaxesDomainMapper extends SequelizeDomainMapper<
CustomerInvoiceTaxModel,
CustomerInvoiceTaxCreationAttributes,
InvoiceTaxGroup
> {
private _taxCatalog: JsonTaxCatalogProvider;
constructor(params: MapperParamsType) {
super();
const { taxCatalog } = params as {
taxCatalog: JsonTaxCatalogProvider;
};
if (!taxCatalog) {
throw new Error('taxCatalog not defined ("TaxesMapper")');
}
this._taxCatalog = taxCatalog;
}
public mapToDomain(
source: CustomerInvoiceTaxModel,
params?: MapperParamsType
): Result<InvoiceTaxGroup, Error> {
/*const { attributes, errors, index } = params as {
index: number;
errors: ValidationErrorDetail[];
attributes: Partial<CustomerInvoiceItemProps>;
};
const currency_code = attributes.currencyCode!.code;
const iva = extractOrPushError(
maybeFromNullableVO(source.iva_code, (code) => Tax.createFromCode(code, this._taxCatalog)),
`taxes[${index}].iva_code`,
errors
);
const rec = extractOrPushError(
maybeFromNullableVO(source.rec_code, (code) => Tax.createFromCode(code, this._taxCatalog)),
`items[${index}].rec_code`,
errors
);
const retention = extractOrPushError(
maybeFromNullableVO(source.retention_code, (code) =>
Tax.createFromCode(code, this._taxCatalog)
),
`items[${index}].retention_code`,
errors
);
// Si hubo errores de mapeo, devolvemos colección de validación
if (errors.length > 0) {
return Result.fail(
new ValidationErrorCollection("Customer invoice taxes mapping failed [mapToDomain]", errors)
);
}
const createResult = InvoiceTaxGroup.create({
iva
})
return Result.ok();*/
throw new Error("Se calcula a partir de las líneas de detalle");
}
public mapToPersistence(
source: InvoiceTaxGroup,
params?: MapperParamsType
): Result<CustomerInvoiceTaxCreationAttributes, Error> {
const { errors, parent } = params as {
parent: CustomerInvoice;
errors: ValidationErrorDetail[];
};
try {
const { ivaAmount, recAmount, retentionAmount } = source.calculateAmounts();
const totalTaxes = ivaAmount.add(recAmount).add(retentionAmount);
const dto: CustomerInvoiceTaxCreationAttributes = {
tax_id: UniqueID.generateNewID().toPrimitive(),
invoice_id: parent.id.toPrimitive(),
// TAXABLE AMOUNT
taxable_amount_value: source.taxableAmount.value,
taxable_amount_scale: source.taxableAmount.scale,
// IVA
iva_code: source.iva.code,
iva_percentage_value: source.iva.value,
iva_percentage_scale: source.iva.scale,
iva_amount_value: ivaAmount.value,
iva_amount_scale: ivaAmount.scale,
// REC
rec_code: toNullable(source.rec, (v) => v.code),
rec_percentage_value: toNullable(source.rec, (v) => v.percentage.value),
rec_percentage_scale: toNullable(source.rec, (v) => v.percentage.scale) ?? 2,
rec_amount_value: recAmount.value,
rec_amount_scale: recAmount.scale,
// RET
retention_code: toNullable(source.retention, (v) => v.code),
retention_percentage_value: toNullable(source.retention, (v) => v.percentage.value),
retention_percentage_scale: toNullable(source.retention, (v) => v.percentage.scale) ?? 2,
retention_amount_value: retentionAmount.value,
retention_amount_scale: retentionAmount.scale,
// TOTAL
taxes_amount_value: totalTaxes.value,
taxes_amount_scale: totalTaxes.scale,
};
return Result.ok(dto);
} catch (error: unknown) {
return Result.fail(error as Error);
}
}
}

View File

@ -16,7 +16,7 @@ import {
maybeFromNullableVO,
toNullable,
} from "@repo/rdx-ddd";
import { Collection, Maybe, Result, isNullishOrEmpty } from "@repo/rdx-utils";
import { Maybe, Result, isNullishOrEmpty } from "@repo/rdx-utils";
import {
CustomerInvoice,
@ -30,8 +30,8 @@ import {
import type { CustomerInvoiceCreationAttributes, CustomerInvoiceModel } from "../../sequelize";
import { CustomerInvoiceItemDomainMapper } from "./customer-invoice-item.mapper";
import { CustomerInvoiceTaxesDomainMapper } from "./customer-invoice-taxes.mapper";
import { InvoiceRecipientDomainMapper } from "./invoice-recipient.mapper";
import { TaxesDomainMapper } from "./invoice-taxes.mapper";
import { CustomerInvoiceVerifactuDomainMapper } from "./invoice-verifactu.mapper";
export interface ICustomerInvoiceDomainMapper
@ -51,7 +51,7 @@ export class CustomerInvoiceDomainMapper
{
private _itemsMapper: CustomerInvoiceItemDomainMapper;
private _recipientMapper: InvoiceRecipientDomainMapper;
private _taxesMapper: TaxesDomainMapper;
private _taxesMapper: CustomerInvoiceTaxesDomainMapper;
private _verifactuMapper: CustomerInvoiceVerifactuDomainMapper;
constructor(params: MapperParamsType) {
@ -59,7 +59,7 @@ export class CustomerInvoiceDomainMapper
this._itemsMapper = new CustomerInvoiceItemDomainMapper(params); // Instanciar el mapper de items
this._recipientMapper = new InvoiceRecipientDomainMapper();
this._taxesMapper = new TaxesDomainMapper(params);
this._taxesMapper = new CustomerInvoiceTaxesDomainMapper(params);
this._verifactuMapper = new CustomerInvoiceVerifactuDomainMapper();
}
@ -250,6 +250,7 @@ export class CustomerInvoiceDomainMapper
const items = CustomerInvoiceItems.create({
languageCode: attributes.languageCode!,
currencyCode: attributes.currencyCode!,
globalDiscountPercentage: attributes.discountPercentage!,
items: itemsResults.data.getAll(),
});
@ -319,7 +320,7 @@ export class CustomerInvoiceDomainMapper
}
// 2) Taxes
const taxesResult = this._taxesMapper.mapToPersistenceArray(new Collection(source.getTaxes()), {
const taxesResult = this._taxesMapper.mapToPersistenceArray(source.getTaxes(), {
errors,
parent: source,
...params,
@ -357,7 +358,7 @@ export class CustomerInvoiceDomainMapper
const taxes = taxesResult.data;
const verifactu = verifactuResult.data;
const allAmounts = source.getAllAmounts(); // Da los totales ya calculados
const allAmounts = source.calculateAllAmounts(); // Da los totales ya calculados
const invoiceValues: Partial<CustomerInvoiceCreationAttributes> = {
// Identificación
@ -386,8 +387,8 @@ export class CustomerInvoiceDomainMapper
discount_percentage_value: source.discountPercentage.toPrimitive().value,
discount_percentage_scale: source.discountPercentage.toPrimitive().scale,
discount_amount_value: allAmounts.headerDiscountAmount.value,
discount_amount_scale: allAmounts.headerDiscountAmount.scale,
discount_amount_value: allAmounts.globalDiscountAmount.value,
discount_amount_scale: allAmounts.globalDiscountAmount.scale,
taxable_amount_value: allAmounts.taxableAmount.value,
taxable_amount_scale: allAmounts.taxableAmount.scale,

View File

@ -1,100 +0,0 @@
import type { JsonTaxCatalogProvider } from "@erp/core";
import { type MapperParamsType, SequelizeDomainMapper, Tax } from "@erp/core/api";
import { UniqueID, type ValidationErrorDetail } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { type CustomerInvoice, type CustomerInvoiceItemProps, ItemAmount } from "../../../domain";
import type {
CustomerInvoiceTaxCreationAttributes,
CustomerInvoiceTaxModel,
} from "../../sequelize";
/**
* Mapper para customer_invoice_taxes
*
* Domina estructuras:
* {
* tax: Tax
* taxableAmount: ItemAmount
* taxesAmount: ItemAmount
* }
*
* Cada fila = un impuesto agregado en toda la factura.
*/
export class TaxesDomainMapper extends SequelizeDomainMapper<
CustomerInvoiceTaxModel,
CustomerInvoiceTaxCreationAttributes,
{ taxableAmount: ItemAmount; tax: Tax; taxesAmount: ItemAmount }
> {
private _taxCatalog: JsonTaxCatalogProvider;
constructor(params: MapperParamsType) {
super();
const { taxCatalog } = params as {
taxCatalog: JsonTaxCatalogProvider;
};
if (!taxCatalog) {
throw new Error('taxCatalog not defined ("TaxesMapper")');
}
this._taxCatalog = taxCatalog;
}
public mapToDomain(
source: CustomerInvoiceTaxModel,
params?: MapperParamsType
): Result<
{
taxableAmount: ItemAmount;
tax: Tax;
taxesAmount: ItemAmount;
},
Error
> {
const { attributes } = params as {
attributes: Partial<CustomerInvoiceItemProps>;
};
const currency_code = attributes.currencyCode!.code;
return Result.ok({
taxableAmount: ItemAmount.create({
value: source.taxable_amount_value,
currency_code,
}).data,
tax: Tax.createFromCode(source.tax_code, this._taxCatalog).data,
taxesAmount: ItemAmount.create({
value: source.taxes_amount_value,
currency_code,
}).data,
});
}
public mapToPersistence(
source: {
taxableAmount: ItemAmount;
tax: Tax;
taxesAmount: ItemAmount;
},
params?: MapperParamsType
): Result<CustomerInvoiceTaxCreationAttributes, Error> {
const { errors, parent } = params as {
parent: CustomerInvoice;
errors: ValidationErrorDetail[];
};
return Result.ok({
tax_id: UniqueID.generateNewID().toPrimitive(),
invoice_id: parent.id.toPrimitive(),
tax_code: source.tax.code,
taxable_amount_value: source.taxableAmount.value,
taxable_amount_scale: source.taxableAmount.scale,
taxes_amount_value: source.taxesAmount.value,
taxes_amount_scale: source.taxesAmount.scale,
});
}
}

View File

@ -22,7 +22,6 @@ import {
CustomerInvoiceStatus,
InvoiceAmount,
type InvoiceRecipient,
ItemAmount,
type VerifactuRecord,
} from "../../../domain";
import type { CustomerInvoiceModel } from "../../sequelize";
@ -51,12 +50,6 @@ export type CustomerInvoiceListDTO = {
languageCode: LanguageCode;
currencyCode: CurrencyCode;
taxes: {
tax_code: string;
taxable_amount: InvoiceAmount;
taxes_amount: InvoiceAmount;
}[];
discountPercentage: Percentage;
subtotalAmount: InvoiceAmount;
@ -107,25 +100,6 @@ export class CustomerInvoiceListMapper
});
}
// 3) Taxes
const taxes = raw.taxes.map((tax) => {
const taxableAmount = ItemAmount.create({
value: tax.taxable_amount_value || 0,
currency_code: attributes.currencyCode!.code,
}).data;
const taxesAmount = ItemAmount.create({
value: tax.taxes_amount_value || 0,
currency_code: attributes.currencyCode!.code,
}).data;
return {
tax_code: tax.tax_code,
taxable_amount: taxableAmount,
taxes_amount: taxesAmount,
};
});
// 4) Verifactu record
let verifactu: Maybe<VerifactuRecord> = Maybe.none();
if (raw.verifactu) {
@ -167,8 +141,6 @@ export class CustomerInvoiceListMapper
languageCode: attributes.languageCode!,
currencyCode: attributes.currencyCode!,
taxes,
discountPercentage: attributes.discountPercentage!,
subtotalAmount: attributes.subtotalAmount!,
discountAmount: attributes.discountAmount!,

View File

@ -32,7 +32,7 @@ export class CustomerInvoiceItemModel extends Model<
declare unit_amount_value: CreationOptional<number | null>;
declare unit_amount_scale: number;
// Subtotal
// Subtotal (cantidad * importe unitario)
declare subtotal_amount_value: CreationOptional<number | null>;
declare subtotal_amount_scale: number;
@ -44,12 +44,22 @@ export class CustomerInvoiceItemModel extends Model<
declare discount_amount_value: CreationOptional<number | null>;
declare discount_amount_scale: number;
// Taxable amount (base imponible)
// Porcentaje de descuento global proporcional a esta línea.
declare global_discount_percentage_value: CreationOptional<number | null>;
declare global_discount_percentage_scale: number;
// Importe del descuento global para esta línea
declare global_discount_amount_value: CreationOptional<number | null>;
declare global_discount_amount_scale: number;
// Suma de los dos descuentos: el de la linea + el global
declare total_discount_amount_value: CreationOptional<number | null>;
declare total_discount_amount_scale: number;
// Taxable amount (base imponible tras los dos descuentos)
declare taxable_amount_value: CreationOptional<number | null>;
declare taxable_amount_scale: number;
// Código de impuestos
// IVA percentage
declare iva_code: CreationOptional<string | null>;
@ -200,6 +210,42 @@ export default (database: Sequelize) => {
defaultValue: 4,
},
global_discount_percentage_value: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
},
global_discount_percentage_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 2,
},
global_discount_amount_value: {
type: new DataTypes.BIGINT(),
allowNull: true,
defaultValue: null,
},
global_discount_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 4,
},
total_discount_amount_value: {
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: true,
defaultValue: null,
},
total_discount_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 4,
},
taxable_amount_value: {
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: true,

View File

@ -1,4 +1,5 @@
import {
type CreationOptional,
DataTypes,
type InferAttributes,
type InferCreationAttributes,
@ -21,14 +22,45 @@ export class CustomerInvoiceTaxModel extends Model<
declare tax_id: string;
declare invoice_id: string;
declare tax_code: string; //"iva_21"
// Taxable amount (base imponible) // 100,00 €
declare taxable_amount_value: number;
// Taxable amount (base imponible)
declare taxable_amount_value: CreationOptional<number | null>;
declare taxable_amount_scale: number;
// Total tax amount / taxes total // 21,00 €
declare taxes_amount_value: number;
// Código de impuestos
// IVA percentage
declare iva_code: CreationOptional<string | null>;
declare iva_percentage_value: CreationOptional<number | null>;
declare iva_percentage_scale: number;
// IVA amount
declare iva_amount_value: CreationOptional<number | null>;
declare iva_amount_scale: number;
// Recargo de equivalencia percentage
declare rec_code: CreationOptional<string | null>;
declare rec_percentage_value: CreationOptional<number | null>;
declare rec_percentage_scale: number;
// Recargo de equivalencia amount
declare rec_amount_value: CreationOptional<number | null>;
declare rec_amount_scale: number;
// Retention percentage
declare retention_code: CreationOptional<string | null>;
declare retention_percentage_value: CreationOptional<number | null>;
declare retention_percentage_scale: number;
// Retention amount
declare retention_amount_value: CreationOptional<number | null>;
declare retention_amount_scale: number;
// Total taxes amount / taxes total
declare taxes_amount_value: CreationOptional<number | null>;
declare taxes_amount_scale: number;
// Relaciones
@ -74,33 +106,115 @@ export default (database: Sequelize) => {
allowNull: false,
},
tax_code: {
type: new DataTypes.STRING(40), // Sugerido por IA
allowNull: false,
},
taxable_amount_value: {
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: false,
defaultValue: 0,
allowNull: true,
defaultValue: null,
},
taxable_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 4,
},
// IVA %
iva_code: {
type: DataTypes.STRING(40),
allowNull: true,
defaultValue: null,
},
iva_percentage_value: {
type: DataTypes.SMALLINT,
allowNull: true,
defaultValue: null,
},
iva_percentage_scale: {
type: DataTypes.SMALLINT,
allowNull: false,
defaultValue: 2,
},
iva_amount_value: {
type: DataTypes.BIGINT,
allowNull: true,
defaultValue: null,
},
iva_amount_scale: {
type: DataTypes.SMALLINT,
allowNull: false,
defaultValue: 4,
},
// REC %
rec_code: {
type: DataTypes.STRING(40),
allowNull: true,
defaultValue: null,
},
rec_percentage_value: {
type: DataTypes.SMALLINT,
allowNull: true,
defaultValue: null,
},
rec_percentage_scale: {
type: DataTypes.SMALLINT,
allowNull: false,
defaultValue: 2,
},
rec_amount_value: {
type: DataTypes.BIGINT,
allowNull: true,
defaultValue: null,
},
rec_amount_scale: {
type: DataTypes.SMALLINT,
allowNull: false,
defaultValue: 4,
},
// Retención %
retention_code: {
type: DataTypes.STRING(40),
allowNull: true,
defaultValue: null,
},
retention_percentage_value: {
type: DataTypes.SMALLINT,
allowNull: true,
defaultValue: null,
},
retention_percentage_scale: {
type: DataTypes.SMALLINT,
allowNull: false,
defaultValue: 2,
},
retention_amount_value: {
type: DataTypes.BIGINT,
allowNull: true,
defaultValue: null,
},
retention_amount_scale: {
type: DataTypes.SMALLINT,
allowNull: false,
defaultValue: 4,
},
taxes_amount_value: {
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: false,
defaultValue: 0,
allowNull: true,
defaultValue: null,
},
taxes_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 2,
defaultValue: 4,
},
},
{
@ -116,8 +230,8 @@ export default (database: Sequelize) => {
fields: ["invoice_id"],
},
{
name: "invoice_tax_code_unique",
fields: ["invoice_id", "tax_code"],
name: "invoice_iva_code_unique",
fields: ["invoice_id", "iva_code"],
unique: true, // cada impuesto aparece como máximo una vez
},
],

View File

@ -35,8 +35,20 @@ export const GetIssuedInvoiceByIdResponseSchema = z.object({
taxes: z.array(
z.object({
tax_code: z.string(),
taxable_amount: MoneySchema,
iva_code: z.string(),
iva_percentage: PercentageSchema,
iva_amount: MoneySchema,
rec_code: z.string(),
rec_percentage: PercentageSchema,
rec_amount: MoneySchema,
retention_code: z.string(),
retention_percentage: PercentageSchema,
retention_amount: MoneySchema,
taxes_amount: MoneySchema,
})
),
@ -53,6 +65,9 @@ export const GetIssuedInvoiceByIdResponseSchema = z.object({
discount_percentage: PercentageSchema,
discount_amount: MoneySchema,
taxable_amount: MoneySchema,
iva_amount: MoneySchema,
rec_amount: MoneySchema,
retention_amount: MoneySchema,
taxes_amount: MoneySchema,
total_amount: MoneySchema,
@ -68,16 +83,34 @@ export const GetIssuedInvoiceByIdResponseSchema = z.object({
is_valued: z.string(),
position: z.string(),
description: z.string(),
quantity: QuantitySchema,
unit_amount: MoneySchema,
tax_codes: z.array(z.string()),
subtotal_amount: MoneySchema,
discount_percentage: PercentageSchema,
discount_amount: MoneySchema,
global_discount_percentage: PercentageSchema,
global_discount_amount: MoneySchema,
taxable_amount: MoneySchema,
iva_code: z.string(),
iva_percentage: PercentageSchema,
iva_amount: MoneySchema,
rec_code: z.string(),
rec_percentage: PercentageSchema,
rec_amount: MoneySchema,
retention_code: z.string(),
retention_percentage: PercentageSchema,
retention_amount: MoneySchema,
taxes_amount: MoneySchema,
total_amount: MoneySchema,
})
),

View File

@ -38,14 +38,6 @@ export const ListIssuedInvoicesResponseSchema = createPaginatedListSchema(
country: z.string(),
}),
taxes: z.array(
z.object({
tax_code: z.string(),
taxable_amount: MoneySchema,
taxes_amount: MoneySchema,
})
),
subtotal_amount: MoneySchema,
discount_percentage: PercentageSchema,
discount_amount: MoneySchema,

View File

@ -35,8 +35,20 @@ export const GetProformaByIdResponseSchema = z.object({
taxes: z.array(
z.object({
tax_code: z.string(),
taxable_amount: MoneySchema,
iva_code: z.string(),
iva_percentage: PercentageSchema,
iva_amount: MoneySchema,
rec_code: z.string(),
rec_percentage: PercentageSchema,
rec_amount: MoneySchema,
retention_code: z.string(),
retention_percentage: PercentageSchema,
retention_amount: MoneySchema,
taxes_amount: MoneySchema,
})
),
@ -53,6 +65,9 @@ export const GetProformaByIdResponseSchema = z.object({
discount_percentage: PercentageSchema,
discount_amount: MoneySchema,
taxable_amount: MoneySchema,
iva_amount: MoneySchema,
rec_amount: MoneySchema,
retention_amount: MoneySchema,
taxes_amount: MoneySchema,
total_amount: MoneySchema,
@ -62,16 +77,34 @@ export const GetProformaByIdResponseSchema = z.object({
is_valued: z.string(),
position: z.string(),
description: z.string(),
quantity: QuantitySchema,
unit_amount: MoneySchema,
tax_codes: z.array(z.string()),
subtotal_amount: MoneySchema,
discount_percentage: PercentageSchema,
discount_amount: MoneySchema,
global_discount_percentage: PercentageSchema,
global_discount_amount: MoneySchema,
taxable_amount: MoneySchema,
iva_code: z.string(),
iva_percentage: PercentageSchema,
iva_amount: MoneySchema,
rec_code: z.string(),
rec_percentage: PercentageSchema,
rec_amount: MoneySchema,
retention_code: z.string(),
retention_percentage: PercentageSchema,
retention_amount: MoneySchema,
taxes_amount: MoneySchema,
total_amount: MoneySchema,
})
),

View File

@ -38,14 +38,6 @@ export const ListProformasResponseSchema = createPaginatedListSchema(
country: z.string(),
}),
taxes: z.array(
z.object({
tax_code: z.string(),
taxable_amount: MoneySchema,
taxes_amount: MoneySchema,
})
),
subtotal_amount: MoneySchema,
discount_percentage: PercentageSchema,
discount_amount: MoneySchema,

View File

@ -1,8 +1,9 @@
import { enforceTenant, enforceUser, mockUser, RequestWithAuth } from "@erp/auth/api";
import { ModuleParams, validateRequest } from "@erp/core/api";
import { ILogger } from "@repo/rdx-logger";
import { Application, NextFunction, Request, Response, Router } from "express";
import { Sequelize } from "sequelize";
import { type RequestWithAuth, enforceTenant, enforceUser, mockUser } from "@erp/auth/api";
import { type ModuleParams, validateRequest } from "@erp/core/api";
import type { ILogger } from "@repo/rdx-logger";
import { type Application, type NextFunction, type Request, type Response, Router } from "express";
import type { Sequelize } from "sequelize";
import {
CreateCustomerRequestSchema,
CustomerListRequestSchema,
@ -11,6 +12,7 @@ import {
UpdateCustomerByIdRequestSchema,
} from "../../../common/dto";
import { buildCustomerDependencies } from "../dependencies";
import {
CreateCustomerController,
GetCustomerController,
@ -31,7 +33,7 @@ export const customersRouter = (params: ModuleParams) => {
const router: Router = Router({ mergeParams: true });
// 🔐 Autenticación + Tenancy para TODO el router
if (process.env.NODE_ENV === "development") {
if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "production") {
router.use(
(req: Request, res: Response, next: NextFunction) =>
mockUser(req as RequestWithAuth, res, next) // Debe ir antes de las rutas protegidas