2025-10-07 16:38:03 +00:00
|
|
|
|
import { MoneyDTO, PercentageDTO, TaxCatalogProvider } from "@erp/core";
|
2025-10-06 17:40:37 +00:00
|
|
|
|
import { useMoney, usePercentage, useQuantity } from "@erp/core/hooks";
|
2025-10-07 16:38:03 +00:00
|
|
|
|
import * as React from "react";
|
|
|
|
|
|
import { CustomerInvoiceItem } from "../schemas";
|
2025-10-06 17:40:37 +00:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Recalcula todos los importes de una línea usando los hooks de escala.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2025-10-08 13:11:26 +00:00
|
|
|
|
type UseCalculateItemAmountsParams = {
|
2025-10-07 16:38:03 +00:00
|
|
|
|
locale: string;
|
|
|
|
|
|
currencyCode: string;
|
|
|
|
|
|
keepNullWhenEmpty?: boolean; // Mantener todos los importes a null cuando la línea está “vacía” (qty+unit vacíos)
|
|
|
|
|
|
taxCatalog: TaxCatalogProvider; // Catálogo de impuestos (inyectable para test)
|
|
|
|
|
|
};
|
2025-10-06 17:40:37 +00:00
|
|
|
|
|
2025-10-08 13:11:26 +00:00
|
|
|
|
export function useCalculateItemAmounts(params: UseCalculateItemAmountsParams) {
|
|
|
|
|
|
const { locale, currencyCode, taxCatalog, keepNullWhenEmpty } = params;
|
2025-10-06 17:40:37 +00:00
|
|
|
|
|
2025-10-07 16:38:03 +00:00
|
|
|
|
const {
|
|
|
|
|
|
add,
|
|
|
|
|
|
sub,
|
|
|
|
|
|
multiply,
|
|
|
|
|
|
percentage: moneyPct,
|
|
|
|
|
|
fromNumber,
|
|
|
|
|
|
toNumber,
|
|
|
|
|
|
isEmptyMoneyDTO,
|
|
|
|
|
|
fallbackCurrency,
|
|
|
|
|
|
} = useMoney();
|
|
|
|
|
|
const { toNumber: qtyToNumber } = useQuantity();
|
|
|
|
|
|
const { toNumber: pctToNumber } = usePercentage();
|
2025-10-06 17:40:37 +00:00
|
|
|
|
|
2025-10-07 16:38:03 +00:00
|
|
|
|
// Crea un MoneyDTO "cero" con la misma divisa/escala que unit_amount
|
|
|
|
|
|
const zeroOf = React.useCallback(
|
|
|
|
|
|
(unit?: MoneyDTO | null): MoneyDTO => {
|
|
|
|
|
|
const cur = unit?.currency_code ?? fallbackCurrency;
|
|
|
|
|
|
const sc = Number(unit?.scale ?? 2);
|
|
|
|
|
|
return fromNumber(0, cur as any, sc);
|
|
|
|
|
|
},
|
|
|
|
|
|
[fromNumber, fallbackCurrency]
|
|
|
|
|
|
);
|
2025-10-06 17:40:37 +00:00
|
|
|
|
|
2025-10-07 16:38:03 +00:00
|
|
|
|
const emptyAmountDTO = React.useMemo(
|
|
|
|
|
|
() => ({
|
|
|
|
|
|
value: "",
|
|
|
|
|
|
scale: "4",
|
|
|
|
|
|
currency_code: currencyCode,
|
|
|
|
|
|
}),
|
|
|
|
|
|
[]
|
|
|
|
|
|
);
|
2025-10-06 17:40:37 +00:00
|
|
|
|
|
2025-10-07 16:38:03 +00:00
|
|
|
|
return React.useCallback(
|
|
|
|
|
|
(item: CustomerInvoiceItem): CustomerInvoiceItem => {
|
|
|
|
|
|
const qty = qtyToNumber(item.quantity); // 0 si vacío
|
|
|
|
|
|
const unit = item.unit_amount && !isEmptyMoneyDTO(item.unit_amount) ? item.unit_amount : null;
|
|
|
|
|
|
const zero = zeroOf(unit ?? undefined);
|
2025-10-06 17:40:37 +00:00
|
|
|
|
|
2025-10-07 16:38:03 +00:00
|
|
|
|
// Línea “vacía”: mantener null si se pide y no hay datos
|
|
|
|
|
|
const isEmptyLine = qty === 0 && (!unit || toNumber(unit) === 0);
|
|
|
|
|
|
if (isEmptyLine && keepNullWhenEmpty) {
|
2025-10-06 17:40:37 +00:00
|
|
|
|
return {
|
2025-10-07 16:38:03 +00:00
|
|
|
|
...item,
|
|
|
|
|
|
subtotal_amount: emptyAmountDTO,
|
|
|
|
|
|
discount_amount: emptyAmountDTO,
|
|
|
|
|
|
taxable_amount: emptyAmountDTO,
|
|
|
|
|
|
taxes_amount: emptyAmountDTO,
|
|
|
|
|
|
total_amount: emptyAmountDTO,
|
2025-10-06 17:40:37 +00:00
|
|
|
|
};
|
2025-10-07 16:38:03 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 1) Subtotal = qty × unit
|
|
|
|
|
|
const subtotal = unit ? multiply(unit, qty) : zero;
|
|
|
|
|
|
|
|
|
|
|
|
// 2) Descuento = subtotal × (discount_percentage / 100)
|
|
|
|
|
|
const pctDTO = item.discount_percentage ?? ({ value: "", scale: "" } as PercentageDTO);
|
|
|
|
|
|
const pct = pctToNumber(pctDTO); // 0 si vacío
|
|
|
|
|
|
const discountAmount = pct !== 0 ? moneyPct(subtotal, Math.abs(pct)) : zero;
|
|
|
|
|
|
|
|
|
|
|
|
// 3) Base imponible = subtotal - descuento
|
|
|
|
|
|
const baseAmount = sub(subtotal, discountAmount);
|
|
|
|
|
|
|
|
|
|
|
|
// 4) Impuestos
|
|
|
|
|
|
const taxesBreakdown =
|
|
|
|
|
|
(item.tax_codes
|
|
|
|
|
|
?.map((code) => {
|
|
|
|
|
|
const maybe = taxCatalog.findByCode(code);
|
|
|
|
|
|
if (maybe.isNone()) {
|
|
|
|
|
|
console.warn(`[useCalculateItemAmounts] Tax code not found: "${code}"`);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const tax = maybe.unwrap()!; // { name, value, scale }
|
|
|
|
|
|
const p = pctToNumber({ value: tax.value, scale: tax.scale }); // ej. 21
|
|
|
|
|
|
return moneyPct(baseAmount, p);
|
|
|
|
|
|
})
|
|
|
|
|
|
.filter(Boolean) as MoneyDTO[]) ?? [];
|
|
|
|
|
|
|
|
|
|
|
|
const taxesTotal = taxesBreakdown.length
|
|
|
|
|
|
? taxesBreakdown.reduce((acc, m) => add(acc, m), zero)
|
|
|
|
|
|
: zero;
|
|
|
|
|
|
|
|
|
|
|
|
// 5) Total = base + impuestos
|
|
|
|
|
|
const total = add(baseAmount, taxesTotal);
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
...item,
|
|
|
|
|
|
subtotal_amount: subtotal,
|
|
|
|
|
|
discount_amount: discountAmount,
|
|
|
|
|
|
taxable_amount: baseAmount,
|
|
|
|
|
|
taxes_amount: taxesTotal,
|
|
|
|
|
|
total_amount: total,
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
[
|
|
|
|
|
|
qtyToNumber,
|
|
|
|
|
|
pctToNumber,
|
|
|
|
|
|
multiply,
|
|
|
|
|
|
moneyPct,
|
|
|
|
|
|
add,
|
|
|
|
|
|
sub,
|
|
|
|
|
|
isEmptyMoneyDTO,
|
|
|
|
|
|
zeroOf,
|
|
|
|
|
|
toNumber,
|
|
|
|
|
|
keepNullWhenEmpty,
|
|
|
|
|
|
taxCatalog,
|
|
|
|
|
|
]
|
|
|
|
|
|
);
|
2025-10-06 17:40:37 +00:00
|
|
|
|
}
|