128 lines
3.9 KiB
TypeScript
128 lines
3.9 KiB
TypeScript
import { MoneyDTO, PercentageDTO, TaxCatalogProvider } from "@erp/core";
|
||
import { useMoney, usePercentage, useQuantity } from "@erp/core/hooks";
|
||
import * as React from "react";
|
||
import { CustomerInvoiceItem } from "../schemas";
|
||
|
||
/**
|
||
* Recalcula todos los importes de una línea usando los hooks de escala.
|
||
*/
|
||
|
||
type UseCalculateItemAmountsParams = {
|
||
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)
|
||
};
|
||
|
||
export function useCalculateItemAmounts(params: UseCalculateItemAmountsParams) {
|
||
const { locale, currencyCode, taxCatalog, keepNullWhenEmpty } = params;
|
||
|
||
const {
|
||
add,
|
||
sub,
|
||
multiply,
|
||
percentage: moneyPct,
|
||
fromNumber,
|
||
toNumber,
|
||
isEmptyMoneyDTO,
|
||
fallbackCurrency,
|
||
} = useMoney();
|
||
const { toNumber: qtyToNumber } = useQuantity();
|
||
const { toNumber: pctToNumber } = usePercentage();
|
||
|
||
// 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]
|
||
);
|
||
|
||
const emptyAmountDTO = React.useMemo(
|
||
() => ({
|
||
value: "",
|
||
scale: "4",
|
||
currency_code: currencyCode,
|
||
}),
|
||
[]
|
||
);
|
||
|
||
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);
|
||
|
||
// Línea “vacía”: mantener null si se pide y no hay datos
|
||
const isEmptyLine = qty === 0 && (!unit || toNumber(unit) === 0);
|
||
if (isEmptyLine && keepNullWhenEmpty) {
|
||
return {
|
||
...item,
|
||
subtotal_amount: emptyAmountDTO,
|
||
discount_amount: emptyAmountDTO,
|
||
taxable_amount: emptyAmountDTO,
|
||
taxes_amount: emptyAmountDTO,
|
||
total_amount: emptyAmountDTO,
|
||
};
|
||
}
|
||
|
||
// 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,
|
||
]
|
||
);
|
||
}
|