Facturas de cliente - Arreglado recálculo de líneas y cabecera

This commit is contained in:
David Arranz 2025-11-11 12:22:20 +01:00
parent 13d24429c6
commit 54e23899c4
19 changed files with 321 additions and 233 deletions

View File

@ -17,7 +17,7 @@ export class CustomerInvoiceItemsFullPresenter extends Presenter {
return {
id: invoiceItem.id.toPrimitive(),
is_non_valued: String(invoiceItem.isNonValued),
is_valued: String(invoiceItem.isValued),
position: String(index),
description: toEmptyString(invoiceItem.description, (value) => value.toPrimitive()),

View File

@ -61,6 +61,8 @@ export class UpdateCustomerInvoiceUseCase {
updatedInvoice.data,
transaction
);
if (invoiceOrError.isFailure) return Result.fail(invoiceOrError.error);
const invoice = invoiceOrError.data;
const dto = presenter.toOutput(invoice);
return Result.ok(dto);

View File

@ -22,7 +22,7 @@ export interface CustomerInvoiceItemProps {
}
export interface ICustomerInvoiceItem {
isNonValued: boolean;
isValued: boolean;
description: Maybe<CustomerInvoiceItemDescription>;
@ -48,7 +48,7 @@ export class CustomerInvoiceItem
extends DomainEntity<CustomerInvoiceItemProps>
implements ICustomerInvoiceItem
{
protected _isNonValued!: boolean;
protected _isValued!: boolean;
public static create(
props: CustomerInvoiceItemProps,
@ -66,11 +66,11 @@ export class CustomerInvoiceItem
protected constructor(props: CustomerInvoiceItemProps, id?: UniqueID) {
super(props, id);
this._isNonValued = this.quantity.isNone() || this.unitAmount.isNone();
this._isValued = this.quantity.isSome() || this.unitAmount.isSome();
}
get isNonValued(): boolean {
return this._isNonValued;
get isValued(): boolean {
return this._isValued;
}
get description(): Maybe<CustomerInvoiceItemDescription> {

View File

@ -95,37 +95,59 @@ export class CustomerInvoiceRepository
* @param transaction - Transacción activa para la operación.
* @returns Result<void, Error>
*/
async update(invoice: CustomerInvoice, transaction?: Transaction): Promise<Result<void, Error>> {
async update(invoice: CustomerInvoice, transaction: Transaction): Promise<Result<void, Error>> {
try {
const mapper: ICustomerInvoiceDomainMapper = this._registry.getDomainMapper({
resource: "customer-invoice",
});
const dto = mapper.mapToPersistence(invoice);
const dtoResult = mapper.mapToPersistence(invoice);
if (dto.isFailure) {
return Result.fail(dto.error);
}
const { id, ...updatePayload } = dto.data;
if (dtoResult.isFailure) return Result.fail(dtoResult.error);
console.log(id);
const dto = dtoResult.data;
const { id, items, taxes, ...updatePayload } = dto;
const [affected] = await CustomerInvoiceModel.update(updatePayload, {
// 1. Actualizar cabecera
const [affectedCount] = await CustomerInvoiceModel.update(updatePayload, {
where: { id /*, version */ },
//fields: Object.keys(updatePayload),
transaction,
individualHooks: true,
});
console.log(affected);
console.log(affectedCount);
if (affected === 0) {
if (affectedCount === 0) {
return Result.fail(
new InfrastructureRepositoryError(
"Concurrency conflict or not found update customer invoice"
)
new InfrastructureRepositoryError(`Invoice ${id} not found or concurrency issue`)
);
}
// 2. Borra items y taxes previos (simplifica sincronización)
await CustomerInvoiceItemModel.destroy({
where: { invoice_id: id },
transaction,
});
await CustomerInvoiceTaxModel.destroy({
where: { invoice_id: id },
transaction,
});
// 3. Inserta taxes de cabecera
if (Array.isArray(taxes) && taxes.length > 0) {
await CustomerInvoiceTaxModel.bulkCreate(taxes, { transaction });
}
// 4. Inserta items + sus taxes
if (Array.isArray(items) && items.length > 0) {
for (const item of items) {
const { taxes: itemTaxes, ...itemData } = item;
await CustomerInvoiceItemModel.create(itemData, { transaction });
if (Array.isArray(itemTaxes) && itemTaxes.length > 0) {
await CustomerInvoiceItemTaxModel.bulkCreate(itemTaxes, { transaction });
}
}
}
return Result.ok();
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));

View File

@ -2,7 +2,7 @@ import { MoneySchema, PercentageSchema, QuantitySchema } from "@erp/core";
import { z } from "zod/v4";
export const UpdateCustomerInvoiceByIdParamsRequestSchema = z.object({
invoice_id: z.string(),
proforma_id: z.string(),
});
export const UpdateCustomerInvoiceByIdRequestSchema = z.object({
@ -23,7 +23,7 @@ export const UpdateCustomerInvoiceByIdRequestSchema = z.object({
items: z
.array(
z.object({
is_non_valued: z.string().optional(),
is_valued: z.string().optional(),
description: z.string().optional(),
quantity: QuantitySchema.optional(),

View File

@ -59,7 +59,7 @@ export const GetCustomerInvoiceByIdResponseSchema = z.object({
items: z.array(
z.object({
id: z.uuid(),
is_non_valued: z.string(),
is_valued: z.string(),
position: z.string(),
description: z.string(),
quantity: QuantitySchema,

View File

@ -173,20 +173,20 @@
"placeholder": "",
"description": "Item unit price"
},
"subtotal_amount": {
"label": "Subtotal",
"placeholder": "",
"description": ""
},
"discount_percentage": {
"label": "Dto (%)",
"placeholder": "",
"description": "Percentage discount"
},
"discount_amount": {
"label": "Discount price",
"label": "Discount amount",
"placeholder": "",
"description": "Percentage discount price"
"description": "Percentage discount amount"
},
"taxable_amount": {
"label": "Taxable amount",
"placeholder": "",
"description": ""
},
"tax_codes": {
"label": "Taxes",
@ -194,12 +194,12 @@
"description": "Taxes"
},
"taxes_amount": {
"label": "Taxes price",
"label": "Taxes amount",
"placeholder": "",
"description": "Percentage taxes price"
"description": "Percentage taxes amount"
},
"total_amount": {
"label": "Total",
"label": "Total amount",
"placeholder": "",
"description": "Invoice line total"
}

View File

@ -165,11 +165,6 @@
"placeholder": "",
"description": "Precio unitario del producto"
},
"subtotal_amount": {
"label": "Subtotal",
"placeholder": "",
"description": ""
},
"discount_percentage": {
"label": "Dto (%)",
"placeholder": "",
@ -180,6 +175,11 @@
"placeholder": "",
"description": "Importe del descuento porcentual"
},
"taxable_amount": {
"label": "Subtotal",
"placeholder": "",
"description": ""
},
"tax_codes": {
"label": "Impuestos",
"placeholder": "",

View File

@ -22,6 +22,11 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
const displayTaxes = useWatch({ control, name: "taxes", defaultValue: [] });
const subtotal_amount = useWatch({ control, name: "subtotal_amount", defaultValue: 0 });
const items_discount_amount = useWatch({
control,
name: "items_discount_amount",
defaultValue: 0,
});
const discount_amount = useWatch({ control, name: "discount_amount", defaultValue: 0 });
const taxable_amount = useWatch({ control, name: "taxable_amount", defaultValue: 0 });
const taxes_amount = useWatch({ control, name: "taxes_amount", defaultValue: 0 });
@ -39,12 +44,21 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
<div className='space-y-1.5'>
{/* Sección: Subtotal y Descuentos */}
<div className='flex justify-between text-sm'>
<span className='text-muted-foreground'>Subtotal sin descuento</span>
<span className='text-muted-foreground'>Subtotal sin descuentos</span>
<span className='font-medium tabular-nums text-muted-foreground'>
{formatCurrency(subtotal_amount, 2, currency_code, language_code)}
</span>
</div>
<div className='flex justify-between text-sm'>
<div className='flex items-center gap-3'>
<span className='text-muted-foreground'>Descuento en líneas</span>
</div>
<span className='font-medium text-destructive tabular-nums'>
-{formatCurrency(items_discount_amount, 2, currency_code, language_code)}
</span>
</div>
<div className='flex justify-between text-sm'>
<div className='flex items-center gap-3'>
<span className='text-muted-foreground'>Descuento global</span>
@ -52,7 +66,7 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
control={control}
name={"discount_percentage"}
readOnly={readOnly}
inputId={"header-discount-percentage"}
inputId={"discount-percentage"}
showSuffix={true}
className={cn(
"w-20 text-right tabular-nums bg-background",

View File

@ -1,32 +1,26 @@
"use client"
"use client";
import { DataTableRowOps } from "@repo/rdx-ui/components";
import { Button, Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
import { Row, Table } from "@tanstack/react-table";
import { DataTableRowOps } from '@repo/rdx-ui/components';
import {
Button,
Tooltip,
TooltipContent,
TooltipTrigger
} from '@repo/shadcn-ui/components';
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, PencilIcon, Trash2Icon } from 'lucide-react';
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, PencilIcon, Trash2Icon } from "lucide-react";
interface DataTableRowActionsProps<TData> {
row: Row<TData>,
table: Table<TData>
row: Row<TData>;
table: Table<TData>;
}
export function ItemDataTableRowActions<TData>({
row, table
}: DataTableRowActionsProps<TData>) {
export function ItemDataTableRowActions<TData>({ row, table }: DataTableRowActionsProps<TData>) {
const ops = (table.options.meta as any)?.rowOps as DataTableRowOps<TData>;
const openEditor = (table.options.meta as any)?.openEditor as (i: number, table: Table<TData>) => void;
const openEditor = (table.options.meta as any)?.openEditor as (
i: number,
table: Table<TData>
) => void;
const lastRow = table.getRowModel().rows.length - 1;
const rowIndex = row.index;
return (
<div className="items-center gap-1 inline-flex">
<div className='items-center gap-1 inline-flex'>
<Tooltip>
<TooltipTrigger asChild>
{openEditor && (
@ -81,17 +75,21 @@ export function ItemDataTableRowActions<TData>({
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
{ops?.move && <Button
type='button'
className='cursor-pointer'
variant='ghost'
size='icon-sm'
aria-label='Move down'
disabled={ops?.canMoveDown ? !ops.canMoveDown(rowIndex, lastRow, table) : rowIndex === lastRow}
onClick={() => ops?.move?.(rowIndex, rowIndex + 1, table)}
>
<ArrowDownIcon className='size-4 text-muted-foreground hover:cursor-pointer' />
</Button>}
{ops?.move && (
<Button
type='button'
className='cursor-pointer'
variant='ghost'
size='icon-sm'
aria-label='Move down'
disabled={
ops?.canMoveDown ? !ops.canMoveDown(rowIndex, lastRow, table) : rowIndex === lastRow
}
onClick={() => ops?.move?.(rowIndex, rowIndex + 1, table)}
>
<ArrowDownIcon className='size-4 text-muted-foreground hover:cursor-pointer' />
</Button>
)}
</TooltipTrigger>
<TooltipContent>Down</TooltipContent>
</Tooltip>

View File

@ -5,7 +5,6 @@ import { useInvoiceContext } from "../../../context";
import { useInvoiceAutoRecalc } from "../../../hooks";
import { useTranslation } from "../../../i18n";
import { defaultCustomerInvoiceItemFormData, InvoiceFormData } from "../../../schemas";
import { debugIdCol } from "./debug-id-col";
import { ItemRowEditor } from "./item-row-editor";
import { useItemsColumns } from "./use-items-columns";
@ -15,7 +14,7 @@ export const ItemsEditor = () => {
const { t } = useTranslation();
const context = useInvoiceContext();
const form = useFormContext<InvoiceFormData>();
const { control, getValues } = form;
const { control } = form;
useInvoiceAutoRecalc(form, context);
@ -25,7 +24,7 @@ export const ItemsEditor = () => {
});
const baseColumns = useWithRowSelection(useItemsColumns(), true);
const columns = useMemo(() => [...baseColumns, debugIdCol], [baseColumns]);
const columns = useMemo(() => baseColumns, [baseColumns]);
return (
<div className='space-y-0'>

View File

@ -1,3 +1,4 @@
import { useMoney } from "@erp/core/hooks";
import {
Button,
Input,
@ -14,17 +15,14 @@ import {
TooltipTrigger,
} from "@repo/shadcn-ui/components";
import { ChevronDownIcon, ChevronUpIcon, CopyIcon, Plus, TrashIcon } from "lucide-react";
import { useMoney } from '@erp/core/hooks';
import { useEffect, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from '../../../i18n';
import { InvoiceItemFormData } from '../../../schemas';
import { HoverCardTotalsSummary } from './hover-card-total-summary';
import { useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../../../i18n";
import { InvoiceItemFormData } from "../../../schemas";
import { HoverCardTotalsSummary } from "./hover-card-total-summary";
import { CustomItemViewProps } from "./types";
export interface TableViewProps extends CustomItemViewProps { }
export interface TableViewProps extends CustomItemViewProps {}
export const TableView = ({ items, actions }: TableViewProps) => {
const { t } = useTranslation();
@ -33,8 +31,8 @@ export const TableView = ({ items, actions }: TableViewProps) => {
const [lines, setLines] = useState<InvoiceItemFormData[]>(items);
useEffect(() => {
setLines(items)
}, [items])
setLines(items);
}, [items]);
// Mantiene sincronía con el formulario padre
const updateItems = (updated: InvoiceItemFormData[]) => {
@ -52,10 +50,7 @@ export const TableView = ({ items, actions }: TableViewProps) => {
/** 🔹 Mueve la fila hacia arriba o abajo */
const moveItem = (index: number, direction: "up" | "down") => {
if (
(direction === "up" && index === 0) ||
(direction === "down" && index === lines.length - 1)
)
if ((direction === "up" && index === 0) || (direction === "down" && index === lines.length - 1))
return;
const newItems = [...lines];
@ -80,7 +75,6 @@ export const TableView = ({ items, actions }: TableViewProps) => {
/** 🔹 Añade una nueva línea vacía */
const addNewItem = () => {
const newItem: InvoiceItemFormData = {
is_non_valued: false,
description: "",
quantity: { value: "0", scale: "2" },
unit_amount: { value: "0", scale: "2", currency_code: "EUR" },
@ -96,60 +90,69 @@ export const TableView = ({ items, actions }: TableViewProps) => {
};
return (
<div className="space-y-4">
<div className="rounded-lg border border-border">
<Table className="min-w-full">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<TableRow className="bg-muted/30 text-xs text-muted-foreground">
<TableHead className="w-10 text-center">#</TableHead>
<div className='space-y-4'>
<div className='rounded-lg border border-border'>
<Table className='min-w-full'>
<TableHeader className='sticky top-0 z-20 bg-background shadow-sm'>
<TableRow className='bg-muted/30 text-xs text-muted-foreground'>
<TableHead className='w-10 text-center'>#</TableHead>
<TableHead>{t("form_fields.item.description.label")}</TableHead>
<TableHead className="text-right w-24">{t("form_fields.item.quantity.label")}</TableHead>
<TableHead className="text-right w-32">{t("form_fields.item.unit_amount.label")}</TableHead>
<TableHead className="text-right w-24">{t("form_fields.item.discount_percentage.label")}</TableHead>
<TableHead className="text-right w-32">{t("form_fields.item.tax_codes.label")}</TableHead>
<TableHead className="text-right w-32">{t("form_fields.item.total_amount.label")}</TableHead>
<TableHead className="w-44 text-center">{t("common.actions")}</TableHead>
<TableHead className='text-right w-24'>
{t("form_fields.item.quantity.label")}
</TableHead>
<TableHead className='text-right w-32'>
{t("form_fields.item.unit_amount.label")}
</TableHead>
<TableHead className='text-right w-24'>
{t("form_fields.item.discount_percentage.label")}
</TableHead>
<TableHead className='text-right w-32'>
{t("form_fields.item.tax_codes.label")}
</TableHead>
<TableHead className='text-right w-32'>
{t("form_fields.item.total_amount.label")}
</TableHead>
<TableHead className='w-44 text-center'>{t("common.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{lines.map((item, i) => (
<TableRow key={`item-${i}`} className="text-sm hover:bg-muted/40">
<TableRow key={`item-${i}`} className='text-sm hover:bg-muted/40'>
{/* ÍNDICE */}
<TableCell className="text-center text-muted-foreground font-mono align-text-top">
<TableCell className='text-center text-muted-foreground font-mono align-text-top'>
{i + 1}
</TableCell>
{/* DESCRIPCIÓN */}
<TableCell className="align-top">
<TableCell className='align-top'>
<Textarea
value={item.description}
onChange={(e) => updateItem(i, { description: e.target.value })}
placeholder="Descripción del producto o servicio…"
className="min-h-[2.5rem] max-h-[10rem] resize-y bg-transparent border-none shadow-none focus:bg-background
placeholder='Descripción del producto o servicio…'
className='min-h-[2.5rem] max-h-[10rem] resize-y bg-transparent border-none shadow-none focus:bg-background
px-2 py-0
leading-5 overflow-y-auto"
leading-5 overflow-y-auto'
aria-label={`Descripción línea ${i + 1}`}
spellCheck={true}
autoComplete="off"
autoComplete='off'
/>
</TableCell>
{/* CANTIDAD */}
<TableCell className="text-right">
<TableCell className='text-right'>
<Input
type="number"
inputMode="decimal"
className="text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1"
type='number'
inputMode='decimal'
className='text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1'
value={Number(item.quantity.value) / 10 ** Number(item.quantity.scale)}
onChange={(e) =>
updateItem(i, {
quantity: {
value: (
Number(e.target.value) * 10 ** Number(item.quantity.scale)
Number(e.target.value) *
10 ** Number(item.quantity.scale)
).toString(),
scale: item.quantity.scale,
},
@ -160,18 +163,19 @@ export const TableView = ({ items, actions }: TableViewProps) => {
</TableCell>
{/* PRECIO UNITARIO */}
<TableCell className="text-right">
<TableCell className='text-right'>
<Input
type="number"
inputMode="decimal"
className="text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1"
type='number'
inputMode='decimal'
className='text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1'
value={Number(item.unit_amount.value) / 10 ** Number(item.unit_amount.scale)}
onChange={(e) =>
updateItem(i, {
unit_amount: {
...item.unit_amount,
value: (
Number(e.target.value) * 10 ** Number(item.unit_amount.scale)
Number(e.target.value) *
10 ** Number(item.unit_amount.scale)
).toString(),
},
})
@ -181,11 +185,11 @@ export const TableView = ({ items, actions }: TableViewProps) => {
</TableCell>
{/* DESCUENTO */}
<TableCell className="text-right">
<TableCell className='text-right'>
<Input
type="number"
inputMode="decimal"
className="text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1"
type='number'
inputMode='decimal'
className='text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1'
value={
Number(item.discount_percentage.value) /
10 ** Number(item.discount_percentage.scale)
@ -205,33 +209,31 @@ export const TableView = ({ items, actions }: TableViewProps) => {
/>
</TableCell>
<TableCell className="text-right">
</TableCell>
<TableCell className='text-right'></TableCell>
{/* TOTAL */}
<TableCell className="text-right font-mono">
<TableCell className='text-right font-mono'>
<HoverCardTotalsSummary item={item}>
<span className="cursor-help hover:text-primary transition-colors">
<span className='cursor-help hover:text-primary transition-colors'>
{format(item.total_amount)}
</span>
</HoverCardTotalsSummary>
</TableCell>
{/* ACCIONES */}
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<TableCell className='text-center'>
<div className='flex items-center justify-center gap-1'>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
variant='ghost'
size='icon'
onClick={() => moveItem(i, "up")}
disabled={i === 0}
className="h-7 w-7"
className='h-7 w-7'
>
<ChevronUpIcon className="size-3.5" />
<ChevronUpIcon className='size-3.5' />
</Button>
</TooltipTrigger>
<TooltipContent>Mover arriba</TooltipContent>
@ -242,13 +244,13 @@ export const TableView = ({ items, actions }: TableViewProps) => {
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
variant='ghost'
size='icon'
onClick={() => moveItem(i, "down")}
disabled={i === lines.length - 1}
className="h-7 w-7"
className='h-7 w-7'
>
<ChevronDownIcon className="size-3.5" />
<ChevronDownIcon className='size-3.5' />
</Button>
</TooltipTrigger>
<TooltipContent>Mover abajo</TooltipContent>
@ -259,12 +261,12 @@ export const TableView = ({ items, actions }: TableViewProps) => {
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
variant='ghost'
size='icon'
onClick={() => duplicateItem(i)}
className="h-7 w-7"
className='h-7 w-7'
>
<CopyIcon className="size-3.5" />
<CopyIcon className='size-3.5' />
</Button>
</TooltipTrigger>
<TooltipContent>Duplicar línea</TooltipContent>
@ -275,12 +277,12 @@ export const TableView = ({ items, actions }: TableViewProps) => {
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
variant='ghost'
size='icon'
onClick={() => removeItem(i)}
className="h-7 w-7 text-destructive hover:text-destructive"
className='h-7 w-7 text-destructive hover:text-destructive'
>
<TrashIcon className="size-3.5" />
<TrashIcon className='size-3.5' />
</Button>
</TooltipTrigger>
<TooltipContent>Eliminar línea</TooltipContent>
@ -296,13 +298,13 @@ export const TableView = ({ items, actions }: TableViewProps) => {
<Button
onClick={addNewItem}
variant="outline"
className="w-full border-dashed bg-transparent"
aria-label="Agregar nueva línea"
variant='outline'
className='w-full border-dashed bg-transparent'
aria-label='Agregar nueva línea'
>
<Plus className="h-4 w-4 mr-2" />
<Plus className='h-4 w-4 mr-2' />
Agregar línea
</Button>
</div>
);
}
};

View File

@ -18,7 +18,10 @@ export interface InvoiceItemFormData {
quantity: number | "";
unit_amount: number | "";
discount_percentage: number | "";
discount_amount: number | "";
taxable_amount: number | "";
tax_codes: string[];
taxes_amount: number | "";
total_amount: number | ""; // readonly calculado
}
export interface InvoiceFormData {
@ -70,7 +73,7 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
el.style.height = `${el.scrollHeight}px`;
}}
className={cn(
"min-w-[12rem] max-w-[46rem] w-full resize-none bg-transparent border-dashed transition",
"min-w-48 max-w-184 w-full resize-none bg-transparent border-dashed transition",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-background focus-visible:border-solid",
"focus:resize-y"
)}
@ -179,6 +182,31 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
size: 40,
minSize: 40,
},
{
accessorKey: "discount_amount",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("form_fields.item.discount_amount.label")}
className='text-right'
/>
),
cell: ({ row }) => (
<AmountInputField
control={control}
name={`items.${row.index}.discount_amount`}
readOnly
inputId={`discount_amount-${row.original.id}`}
currencyCode={currency_code}
languageCode={language_code}
/>
),
enableHiding: true,
enableSorting: false,
size: 120,
minSize: 100,
maxSize: 160,
},
{
accessorKey: "taxable_amount",
header: ({ column }) => (
@ -192,17 +220,13 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
<AmountInputField
control={control}
name={`items.${row.index}.taxable_amount`}
readOnly={readOnly}
inputId={`unit-${row.original.id}`}
scale={4}
readOnly
inputId={`taxable_amount-${row.original.id}`}
currencyCode={currency_code}
languageCode={language_code}
data-row-index={row.index}
data-col-index={5}
data-cell-focus
className='font-base'
/>
),
enableHiding: true,
enableSorting: false,
size: 120,
minSize: 100,
@ -233,6 +257,30 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
minSize: 130,
maxSize: 180,
},
{
accessorKey: "taxes_amount",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("form_fields.item.taxes_amount.label")}
className='text-right'
/>
),
cell: ({ row }) => (
<AmountInputField
control={control}
name={`items.${row.index}.taxes_amount`}
readOnly
inputId={`taxes_amount-${row.original.id}`}
currencyCode={currency_code}
languageCode={language_code}
/>
),
enableSorting: false,
size: 120,
minSize: 100,
maxSize: 160,
},
{
accessorKey: "total_amount",
header: ({ column }) => (
@ -266,6 +314,10 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
<DataTableColumnHeader column={column} title={t("components.datatable.actions")} />
),
cell: ({ row, table }) => <ItemDataTableRowActions row={row} table={table} />,
enableSorting: false,
size: 100,
minSize: 100,
maxSize: 100,
},
],
[t, readOnly, control, currency_code, language_code]

View File

@ -3,10 +3,9 @@ import type { Dinero } from "dinero.js";
import { InvoiceItemTaxSummary } from "./calculate-invoice-item-amounts";
import { toDinero } from "./calculate-utils";
export interface InvoiceHeaderCalcInput {
export interface InvoiceItemsTotalsInput {
subtotal_amount: number;
discount_amount: number;
header_discount_amount: number;
taxable_amount: number;
taxes_amount: number;
taxes_summary: InvoiceItemTaxSummary[];
@ -15,52 +14,54 @@ export interface InvoiceHeaderCalcInput {
export interface InvoiceHeaderCalcResult {
subtotal_amount: number;
items_discount_amount: number;
discount_amount: number;
header_discount_amount: number;
taxable_amount: number;
taxes_summary: InvoiceItemTaxSummary[];
taxes_amount: number;
total_amount: number;
}
const toNum = (d: Dinero.Dinero) => d.toUnit();
/**
* Agrega los importes de todas las líneas y recalcula los totales generales
* con precisión financiera (sumas exactas en céntimos).
*/
export function calculateInvoiceHeaderAmounts(
items: InvoiceHeaderCalcInput[],
items: InvoiceItemsTotalsInput[],
discount_percentage: number,
currency: string
): InvoiceHeaderCalcResult {
const defaultScale = 2;
let subtotal = toDinero(0, defaultScale, currency);
let discount = toDinero(0, defaultScale, currency);
let header_discount = toDinero(0, defaultScale, currency);
let taxable = toDinero(0, defaultScale, currency);
let taxes = toDinero(0, defaultScale, currency);
let total = toDinero(0, defaultScale, currency);
const taxes_summary: InvoiceItemTaxSummary[] = [];
let items_subtotal = toDinero(0, defaultScale, currency);
let items_discount = toDinero(0, defaultScale, currency);
let items_taxable = toDinero(0, defaultScale, currency);
let items_taxes = toDinero(0, defaultScale, currency);
let items_total = toDinero(0, defaultScale, currency);
const items_taxes_summary: InvoiceItemTaxSummary[] = [];
for (const item of items) {
subtotal = subtotal.add(toDinero(item.subtotal_amount, defaultScale, currency));
discount = discount.add(toDinero(item.discount_amount, defaultScale, currency));
header_discount = header_discount.add(toDinero(item.discount_amount, defaultScale, currency));
taxable = taxable.add(toDinero(item.taxable_amount, defaultScale, currency));
taxes = taxes.add(toDinero(item.taxes_amount, defaultScale, currency));
total = total.add(toDinero(item.total_amount, defaultScale, currency));
taxes_summary.push(...item.taxes_summary);
items_subtotal = items_subtotal.add(toDinero(item.subtotal_amount, defaultScale, currency));
items_discount = items_discount.add(toDinero(item.discount_amount, defaultScale, currency));
items_taxable = items_taxable.add(toDinero(item.taxable_amount, defaultScale, currency));
items_taxes = items_taxes.add(toDinero(item.taxes_amount, defaultScale, currency));
items_total = items_total.add(toDinero(item.total_amount, defaultScale, currency));
items_taxes_summary.push(...item.taxes_summary);
}
const toNum = (d: Dinero.Dinero) => d.toUnit();
// Descuento = subtotal × (item_pct / 100)
const discount_amount = items_taxable.percentage(discount_percentage);
return {
subtotal_amount: toNum(subtotal),
discount_amount: toNum(discount),
header_discount_amount: toNum(header_discount),
taxable_amount: toNum(taxable),
taxes_amount: toNum(taxes),
total_amount: toNum(total),
taxes_summary: calculateTaxesSummary(taxes_summary, currency),
subtotal_amount: toNum(items_subtotal),
items_discount_amount: toNum(items_discount),
discount_amount: toNum(discount_amount),
taxable_amount: toNum(items_taxable),
taxes_amount: toNum(items_taxes),
total_amount: toNum(items_total),
taxes_summary: calculateTaxesSummary(items_taxes_summary, currency),
};
}

View File

@ -5,7 +5,6 @@ export interface InvoiceItemCalcInput {
quantity?: string; // p.ej. "3.5"
unit_amount?: string; // p.ej. "125.75"
discount_percentage?: string; // p.ej. "10" (=> 10%)
header_discount_percentage?: string; // p.ej. "5" (=> 5%)
tax_codes: string[]; // ["iva_21", ...]
}
@ -17,7 +16,6 @@ export type InvoiceItemTaxSummary = TaxItemType & {
export interface InvoiceItemCalcResult {
subtotal_amount: number;
discount_amount: number;
header_discount_amount: number;
taxable_amount: number;
taxes_amount: number;
taxes_summary: InvoiceItemTaxSummary[];
@ -39,7 +37,6 @@ export function calculateInvoiceItemAmounts(
const qty = Number.parseFloat(item.quantity || "0") || 0;
const unit = Number.parseFloat(item.unit_amount || "0") || 0;
const iten_pct = Number.parseFloat(item.discount_percentage || "0") || 0;
const header_pct = Number.parseFloat(item.header_discount_percentage || "0") || 0;
// Subtotal = cantidad × precio unitario
const subtotal_amount = toDinero(unit, defaultScale, currency).multiply(qty);
@ -48,38 +45,36 @@ export function calculateInvoiceItemAmounts(
const discount_amount = subtotal_amount.percentage(iten_pct);
const subtotal_w_discount_amount = subtotal_amount.subtract(discount_amount);
// Descuento de la cabecera = subtotal con dto de línea × (header_pct / 100)
const header_discount = subtotal_w_discount_amount.percentage(header_pct);
// Base imponible
const taxable_amount = subtotal_w_discount_amount.subtract(header_discount);
const taxable_amount = subtotal_w_discount_amount;
// Impuestos acumulados con signo
let taxes_amount = toDinero(0, defaultScale, currency);
for (const code of item.tax_codes ?? []) {
const tax = taxCatalog.findByCode(code);
if (tax.isNone()) continue;
const taxItemType = taxCatalog.findByCode(code);
if (taxItemType.isNone()) continue;
tax.map((taxItem) => {
const tax_pct_value =
Number.parseFloat(taxItem.value) / 10 ** Number.parseInt(taxItem.scale, 10);
const item_taxables_amount = taxable_amount.percentage(tax_pct_value);
const taxItem = taxItemType.unwrap();
// Sumar o restar según grupo
switch (taxItem.group.toLowerCase()) {
case "retención":
taxes_amount = taxes_amount.subtract(item_taxables_amount);
break;
default:
taxes_amount = taxes_amount.add(item_taxables_amount);
break;
}
const tax_pct_value =
Number.parseFloat(taxItem.value) / 10 ** Number.parseInt(taxItem.scale, 10);
const item_taxables_amount = taxable_amount.percentage(tax_pct_value);
taxesSummary.push({
...taxItem,
taxable_amount: toNum(taxable_amount),
taxes_amount: toNum(item_taxables_amount),
});
// Sumar o restar según grupo
switch (taxItem.group.toLowerCase()) {
case "retención":
taxes_amount = taxes_amount.subtract(item_taxables_amount);
break;
default:
taxes_amount = taxes_amount.add(item_taxables_amount);
break;
}
taxesSummary.push({
...taxItem,
taxable_amount: toNum(taxable_amount),
taxes_amount: toNum(item_taxables_amount),
});
}
@ -88,7 +83,6 @@ export function calculateInvoiceItemAmounts(
return {
subtotal_amount: toNum(subtotal_amount),
discount_amount: toNum(discount_amount),
header_discount_amount: toNum(header_discount),
taxable_amount: toNum(taxable_amount),
taxes_amount: toNum(taxes_amount),
taxes_summary: taxesSummary,

View File

@ -22,19 +22,20 @@ export type UseInvoiceAutoRecalcParams = {
*/
export function useInvoiceAutoRecalc(
form: UseFormReturn<InvoiceFormData>,
{ currency_code, taxCatalog, debug = false }: UseInvoiceAutoRecalcParams
{ currency_code, taxCatalog, debug = true }: UseInvoiceAutoRecalcParams
) {
const { setValue, trigger, control } = form;
const { trigger, control } = form;
// Observa los ítems y el descuento global
const watchedItems = useWatch({ control, name: "items" }) ?? [];
const watchedDiscount = useWatch({ control, name: "discount_percentage" }) ?? 0;
const watchedDiscount = useWatch({ control, name: "discount_percentage", defaultValue: 0 }); // <- descuento global
// Diferir valores pesados para reducir renders (React 19)
const deferredItems = React.useDeferredValue(watchedItems);
const deferredDiscount = React.useDeferredValue(watchedDiscount);
// Cache para evitar recálculos redundantes
const [prevDiscount, setPrevDiscount] = React.useState(watchedDiscount);
const itemCache = React.useRef<Map<number, InvoiceItemCalcResult>>(new Map());
// Debounce para agrupar recalculados rápidos
@ -42,7 +43,7 @@ export function useInvoiceAutoRecalc(
// Cálculo de una línea individual
const calculateItemTotals = React.useCallback(
(item: InvoiceItemFormData, header_discount_percentage: number) => {
(item: InvoiceItemFormData) => {
const sanitize = (v?: number | string) => (v && !Number.isNaN(Number(v)) ? String(v) : "0");
return calculateInvoiceItemAmounts(
@ -50,7 +51,6 @@ export function useInvoiceAutoRecalc(
quantity: sanitize(item.quantity),
unit_amount: sanitize(item.unit_amount),
discount_percentage: sanitize(item.discount_percentage),
header_discount_percentage: sanitize(header_discount_percentage),
tax_codes: item.tax_codes,
},
currency_code,
@ -64,13 +64,12 @@ export function useInvoiceAutoRecalc(
const calculateInvoiceTotals = React.useCallback(
(items: InvoiceItemFormData[], header_discount_percentage: number) => {
const lines = items
.filter((i) => !i.is_non_valued)
//.filter((i) => i.is_valued)
.map((i) => {
const totals = calculateItemTotals(i, header_discount_percentage);
const totals = calculateItemTotals(i);
return {
subtotal_amount: totals.subtotal_amount,
discount_amount: totals.discount_amount,
header_discount_amount: totals.header_discount_amount,
taxable_amount: totals.taxable_amount,
taxes_amount: totals.taxes_amount,
total_amount: totals.total_amount,
@ -78,7 +77,7 @@ export function useInvoiceAutoRecalc(
};
});
return calculateInvoiceHeaderAmounts(lines, currency_code);
return calculateInvoiceHeaderAmounts(lines, header_discount_percentage, currency_code);
},
[calculateItemTotals, currency_code]
);
@ -92,9 +91,14 @@ export function useInvoiceAutoRecalc(
debounceTimer.current = setTimeout(() => {
let shouldUpdateHeader = false;
if (prevDiscount !== deferredDiscount) {
shouldUpdateHeader = true;
setPrevDiscount(deferredDiscount);
}
deferredItems.forEach((item, idx) => {
const prev = itemCache.current.get(idx);
const next = calculateItemTotals(item, deferredDiscount);
const next = calculateItemTotals(item);
const itemHasChanges =
!prev ||
@ -107,14 +111,14 @@ export function useInvoiceAutoRecalc(
shouldUpdateHeader = true;
itemCache.current.set(idx, next);
setInvoiceItemTotals(form, idx, next);
if (debug) console.log(`💡 Recalc line ${idx + 1}`, next);
if (debug) console.log(`💡 Recalc line ${idx + 1}`, next.total_amount);
}
});
if (shouldUpdateHeader) {
const totals = calculateInvoiceTotals(deferredItems, deferredDiscount);
setInvoiceTotals(form, totals);
if (debug) console.log("📊 Recalc invoice totals", totals);
if (debug) console.log("📊 Recalc invoice totals", totals.subtotal_amount);
void trigger([
"subtotal_amount",
@ -164,8 +168,11 @@ function setInvoiceTotals(
const { setValue } = form;
const opts = { shouldDirty: true, shouldValidate: false } as const;
console.log(totals);
setValue("subtotal_amount", totals.subtotal_amount, opts);
setValue("discount_amount", totals.header_discount_amount, opts);
setValue("items_discount_amount", totals.items_discount_amount, opts);
setValue("discount_amount", totals.discount_amount, opts);
setValue("taxable_amount", totals.taxable_amount, opts);
setValue("taxes_amount", totals.taxes_amount, opts);
setValue("total_amount", totals.total_amount, opts);

View File

@ -47,7 +47,6 @@ export const invoiceDtoToFormAdapter = {
})),
items: dto.items.map((item) => ({
is_non_valued: item.is_non_valued === "true",
description: item.description ?? "",
quantity: QuantityDTOHelper.toNumericString(item.quantity),
unit_amount: MoneyDTOHelper.toNumericString(item.unit_amount),
@ -80,7 +79,6 @@ export const invoiceDtoToFormAdapter = {
currency_code: context.currency_code,
items: form.items?.map((item) => ({
is_non_valued: item.is_non_valued ? "true" : "false",
description: item.description,
quantity: QuantityDTOHelper.fromNumericString(item.quantity, 4),
unit_amount: MoneyDTOHelper.fromNumericString(item.unit_amount, currency_code, 4),

View File

@ -1,8 +1,6 @@
import { z } from "zod/v4";
export const InvoiceItemFormSchema = z.object({
is_non_valued: z.boolean(),
description: z.string().max(2000).optional().default(""),
quantity: z.any(), //NumericStringSchema.optional(),
unit_amount: z.any(), //NumericStringSchema.optional(),
@ -74,6 +72,7 @@ export const InvoiceFormSchema = z.object({
items: z.array(InvoiceItemFormSchema).optional(),
subtotal_amount: z.number(),
items_discount_amount: z.number(),
discount_percentage: z.number(),
discount_amount: z.number(),
taxable_amount: z.number(),
@ -85,7 +84,6 @@ export type InvoiceFormData = z.infer<typeof InvoiceFormSchema>;
export type InvoiceItemFormData = z.infer<typeof InvoiceItemFormSchema>;
export const defaultCustomerInvoiceItemFormData: InvoiceItemFormData = {
is_non_valued: false,
description: "",
quantity: "",
unit_amount: "",
@ -115,6 +113,7 @@ export const defaultCustomerInvoiceFormData: InvoiceFormData = {
items: [],
subtotal_amount: 0,
items_discount_amount: 0,
discount_amount: 0,
discount_percentage: 0,
taxable_amount: 0,

View File

@ -49,7 +49,7 @@ export const GetVerifactuRecordByIdResponseSchema = z.object({
items: z.array(
z.object({
id: z.uuid(),
is_non_valued: z.string(),
is_valued: z.string(),
position: z.string(),
description: z.string(),
quantity: QuantitySchema,