This commit is contained in:
David Arranz 2026-05-11 21:47:38 +02:00
parent a248e8cdc0
commit 3b77fb7cb8
46 changed files with 1017 additions and 559 deletions

View File

@ -8,24 +8,6 @@
"description": "IVA general. Tipo estándar nacional.",
"aeat_code": "01"
},
{
"name": "IVA 18%",
"code": "iva_18",
"value": "1800",
"scale": "2",
"group": "IVA",
"description": "IVA general. Tipo estándar nacional hasta finales de 2011",
"aeat_code": null
},
{
"name": "IVA 16%",
"code": "iva_16",
"value": "1600",
"scale": "2",
"group": "IVA",
"description": "IVA general. Tipo estándar nacional hasta finales de 2009.",
"aeat_code": null
},
{
"name": "IVA 10%",
"code": "iva_10",

View File

@ -1 +1,3 @@
export * from "./payment-method-options.constants";
export * from "./retentions-options.constants";
export * from "./taxes-options.constants";

View File

@ -0,0 +1,25 @@
export type RetentionPercentageOption = 15 | 7;
export interface ProformaRetentionsDefinition {
retentionPercentage: RetentionPercentageOption;
retentionLabel: string;
}
export const PROFORMA_RETENTION_DEFINITIONS: Record<
RetentionPercentageOption,
ProformaRetentionsDefinition
> = {
15: {
retentionPercentage: 15,
retentionLabel: "General",
},
7: {
retentionPercentage: 7,
retentionLabel: "Reducido",
},
};
export const PROFORMA_RETENTION_OPTIONS = [
{ value: "15", label: "15%" },
{ value: "7", label: "7%" },
];

View File

@ -0,0 +1,37 @@
export type TaxPercentageOption = 21 | 10 | 4 | 0;
export interface ProformaTaxesDefinition {
taxPercentage: TaxPercentageOption;
taxLabel: string;
recPercentage: number;
}
export const PROFORMA_TAX_DEFINITIONS: Record<TaxPercentageOption, ProformaTaxesDefinition> = {
21: {
taxPercentage: 21,
taxLabel: "General",
recPercentage: 5.2,
},
10: {
taxPercentage: 10,
taxLabel: "Reducido",
recPercentage: 1.4,
},
4: {
taxPercentage: 4,
taxLabel: "Superreducido",
recPercentage: 0.5,
},
0: {
taxPercentage: 0,
taxLabel: "Exento",
recPercentage: 0,
},
};
export const PROFORMA_TAX_OPTIONS = [
{ value: "0", label: "0%" },
{ value: "4", label: "4%" },
{ value: "10", label: "10%" },
{ value: "21", label: "21%" },
];

View File

@ -12,11 +12,11 @@ export interface ProformaTaxSummary {
ivaAmount: number;
recCode: string | null;
recPercentage: number;
recPercentage: number | null;
recAmount: number;
retentionCode: string | null;
retentionPercentage: number;
retentionPercentage: number | null;
retentionAmount: number;
taxesAmount: number;

View File

@ -4,3 +4,4 @@ export * from "./constants";
export * from "./entities";
export * from "./hooks";
export * from "./ui";
export * from "./utils";

View File

@ -0,0 +1,5 @@
import { PROFORMA_TAX_DEFINITIONS, type TaxPercentageOption } from "../constants";
export const getProformaRecPercentage = (taxPercentage: TaxPercentageOption): number => {
return PROFORMA_TAX_DEFINITIONS[taxPercentage].recPercentage;
};

View File

@ -0,0 +1,3 @@
import { PROFORMA_RETENTION_OPTIONS } from "../constants";
export const getProformaRetentionOptions = () => PROFORMA_RETENTION_OPTIONS;

View File

@ -0,0 +1,3 @@
import { PROFORMA_TAX_OPTIONS } from "../constants";
export const getProformaTaxOptions = () => PROFORMA_TAX_OPTIONS;

View File

@ -0,0 +1,3 @@
export * from "./get-proforma-rec-percentage";
export * from "./get-proforma-retention-options";
export * from "./get-proforma-tax-options";

View File

@ -1,3 +1,3 @@
export * from "./map-proforma-form-to-commercial-document-lines";
export * from "./map-proforma-items-form-to-proforma-line-inpus";
export * from "./map-proforma-to-proforma-update-form.adapter";
export * from "./map-proforma-to-selected-customer.adapter";

View File

@ -1,15 +0,0 @@
import type { CommercialDocumentLineInput, ProformaUpdateForm } from "../entities";
export const mapProformaFormToCommercialDocumentLines = (
form: ProformaUpdateForm
): CommercialDocumentLineInput[] => {
return form.items
.filter((item) => item.isValued)
.map((item) => ({
quantity: item.quantity,
unitAmount: item.unitAmount,
itemDiscountPercentage: item.itemDiscountPercentage,
taxPercentage: item.taxPercentage,
recPercentage: item.recPercentage,
}));
};

View File

@ -0,0 +1,31 @@
import type { ProformaItemUpdateForm, ProformaLineInput } from "../entities";
/**
* A partir de las líneas del formulario de actualización de proforma,
* devuelve la información de las lineas de la proforma necesarias
* para calcular los totales.
* @param form
* @returns
*/
interface MapProformaItemFormToProformaLineInputsParams {
globalDiscountPercentage: number | null;
}
export const mapProformaItemFormToProformaLineInputs = (
items: ProformaItemUpdateForm[],
params: MapProformaItemFormToProformaLineInputsParams
): ProformaLineInput[] => {
return items
.filter((item) => item.isValued)
.map((item) => ({
quantity: item.quantity,
unitAmount: item.unitAmount,
itemDiscountPercentage: item.itemDiscountPercentage,
globalDiscountPercentage: params.globalDiscountPercentage,
taxPercentage: item.taxPercentage,
recPercentage: item.recPercentage,
}));
};

View File

@ -48,12 +48,17 @@ export const mapProformaToProformaUpdateForm = (proforma: Proforma): ProformaUpd
taxMode,
taxRegimeCode: proformaDefaults.taxRegimeCode,
defaultTaxPercentage,
hasRecPercentage: hasPositivePercentage(defaultRecPercentage),
hasRetention: hasPositivePercentage(defaultRetentionPercentage),
retentionPercentage: defaultRetentionPercentage,
paymentMethod: proforma.paymentMethod ?? "",
hasTaxPercentage: PercentageHelper.hasPositivePercentage(defaultTaxPercentage),
defaultTaxPercentage: defaultTaxPercentage,
hasRecPercentage: PercentageHelper.hasPositivePercentage(defaultRecPercentage),
defaultRecPercentage: defaultRecPercentage,
hasRetentionPercentage: PercentageHelper.hasPositivePercentage(defaultRetentionPercentage),
defaultRetentionPercentage: defaultRetentionPercentage,
paymentMethodId: proforma.paymentMethod ?? proformaDefaults.paymentMethodId,
items: proforma.items.map(mapProformaItemsToProformaItemsUpdateForm),
};
@ -66,11 +71,17 @@ const getFirstTaxableItem = (items: ProformaItem[]): ProformaItem | undefined =>
const inferProformaTaxMode = (items: ProformaItem[]): ProformaTaxMode => {
const comparableItems = items.filter((item) => item.isValued);
const sourceItems = comparableItems.length > 0 ? comparableItems : items;
if (comparableItems.length === 0) {
return "single";
}
const ivaPercentages = uniqueNumbers(sourceItems.map((item) => item.ivaPercentage));
const recPercentages = uniqueNumbers(sourceItems.map((item) => item.recPercentage));
const retentionPercentages = uniqueNumbers(sourceItems.map((item) => item.retentionPercentage));
const sourceItems = comparableItems;
const ivaPercentages = uniquePercentageValues(sourceItems.map((item) => item.ivaPercentage));
const recPercentages = uniquePercentageValues(sourceItems.map((item) => item.recPercentage));
const retentionPercentages = uniquePercentageValues(
sourceItems.map((item) => item.retentionPercentage)
);
const hasSingleTaxSetup =
ivaPercentages.length <= 1 && recPercentages.length <= 1 && retentionPercentages.length <= 1;
@ -78,20 +89,12 @@ const inferProformaTaxMode = (items: ProformaItem[]): ProformaTaxMode => {
return hasSingleTaxSetup ? "single" : "perLine";
};
const uniqueNumbers = (values: Array<number | null | undefined>): number[] => {
const uniquePercentageValues = (values: Array<number | null | undefined>): number[] => {
return Array.from(
new Set(
values
.filter((value): value is number => value !== null && value !== undefined)
.map((value) => normalizePercentage(value))
.map((value) => PercentageHelper.normalizePercentage(value))
)
);
};
const normalizePercentage = (value: number): number => {
return Math.round(value * 10000) / 10000;
};
const hasPositivePercentage = (value: number | null | undefined): boolean => {
return PercentageHelper.hasPositivePercentage(value);
};

View File

@ -162,6 +162,8 @@ export const useUpdateProformaController = (
console.log("Enviando actualización con params:", params);
return;
try {
// Enviamos cambios al servidor
const updated = await mutateAsync(params);

View File

@ -7,10 +7,11 @@ import {
useWatch,
} from "react-hook-form";
import { mapProformaItemFormToProformaLineInputs } from "../adapters";
import type { ProformaItemUpdateForm, ProformaUpdateForm } from "../entities";
import { buildProformaItemUpdateDefault } from "../utils";
import { calculateCommercialDocumentLineAmounts } from "../utils/calculations/calculate-commercial-document-line-amounts";
import { calculateCommercialDocumentLinesTotals } from "../utils/calculations/calculate-commercial-document-lines-totals";
import { calculateProformaLineTotal } from "../utils/calculations/calculate-proforma-line-total";
import { calculateProformaLinesTotals } from "../utils/calculations/calculate-proforma-lines-totals";
export interface ProformaItemAmounts {
subtotal: number;
@ -25,7 +26,6 @@ export interface ProformaItemsTotals {
}
export type ProformaItemField = FieldArrayWithId<ProformaUpdateForm, "items", "fieldId">;
export type ProformaItemError = FieldErrors<ProformaItemUpdateForm>;
export interface UseUpdateProformaItemsControllerResult {
@ -56,10 +56,6 @@ interface UseUpdateProformaItemsControllerParams {
form: UseFormReturn<ProformaUpdateForm>;
}
const roundCurrency = (value: number): number => {
return Math.round(value * 100) / 100;
};
const normalizeItemPositions = (items: ProformaItemUpdateForm[]): ProformaItemUpdateForm[] => {
return items.map((item, index) => ({
...item,
@ -83,11 +79,7 @@ export const useUpdateProformaItemsController = ({
keyName: "fieldId",
});
const watchedItems = useWatch({
control,
name: "items",
});
const watchedItems = useWatch({ control, name: "items" });
const items = React.useMemo(() => watchedItems ?? [], [watchedItems]);
const replaceItems = React.useCallback(
@ -113,9 +105,7 @@ export const useUpdateProformaItemsController = ({
(index: number) => {
const currentItems = getValues("items") ?? [];
if (index < 0 || index >= currentItems.length) {
return;
}
if (index < 0 || index >= currentItems.length) return;
replaceItems(currentItems.filter((_, currentIndex) => currentIndex !== index));
},
@ -127,9 +117,7 @@ export const useUpdateProformaItemsController = ({
const currentItems = getValues("items") ?? [];
const item = currentItems[index];
if (!item) {
return;
}
if (!item) return;
const duplicatedItem: ProformaItemUpdateForm = {
...item,
@ -149,9 +137,7 @@ export const useUpdateProformaItemsController = ({
(index: number) => {
const currentItems = getValues("items") ?? [];
if (index <= 0 || index >= currentItems.length) {
return;
}
if (index <= 0 || index >= currentItems.length) return;
const nextItems = [...currentItems];
[nextItems[index - 1], nextItems[index]] = [nextItems[index], nextItems[index - 1]];
@ -165,9 +151,7 @@ export const useUpdateProformaItemsController = ({
(index: number) => {
const currentItems = getValues("items") ?? [];
if (index < 0 || index >= currentItems.length - 1) {
return;
}
if (index < 0 || index >= currentItems.length - 1) return;
const nextItems = [...currentItems];
[nextItems[index], nextItems[index + 1]] = [nextItems[index + 1], nextItems[index]];
@ -179,16 +163,21 @@ export const useUpdateProformaItemsController = ({
const getItemAmounts = React.useCallback(
(index: number): ProformaItemAmounts => {
const amounts = calculateCommercialDocumentLineAmounts(items[index]);
const line = mapProformaItemFormToProformaLineInputs([items[index]], {
globalDiscountPercentage: null,
})[0];
const amounts = calculateProformaLineTotal(line);
return {
subtotal: amounts.grossAmount,
subtotal: amounts.subtotalBeforeDiscounts,
itemDiscountAmount: amounts.itemDiscountAmount,
total: amounts.taxableBaseBeforeGlobalDiscount,
total: amounts.subtotalBeforeGlobalDiscount,
};
},
[items]
);
const insertItemAt = React.useCallback(
(index: number) => {
const currentItems = getValues("items") ?? [];
@ -221,7 +210,11 @@ export const useUpdateProformaItemsController = ({
);
const totals = React.useMemo<ProformaItemsTotals>(() => {
const lineTotals = calculateCommercialDocumentLinesTotals(items);
const lineTotals = calculateProformaLinesTotals(
mapProformaItemFormToProformaLineInputs(items, {
globalDiscountPercentage: null,
})
);
return {
subtotal: lineTotals.subtotalBeforeDiscounts,
@ -231,9 +224,7 @@ export const useUpdateProformaItemsController = ({
}, [items]);
const itemErrors = React.useMemo<ProformaItemError[]>(() => {
if (!Array.isArray(errors.items)) {
return [];
}
if (!Array.isArray(errors.items)) return [];
return errors.items.map((error) => error ?? {});
}, [errors.items]);

View File

@ -1,6 +1,11 @@
import { useCallback, useEffect, useRef } from "react";
import { type UseFormReturn, useWatch } from "react-hook-form";
import {
type RetentionPercentageOption,
type TaxPercentageOption,
getProformaRecPercentage,
} from "../../shared";
import type { ProformaTaxMode, ProformaUpdateForm } from "../entities";
interface UseUpdateProformaTaxControllerParams {
@ -10,24 +15,35 @@ interface UseUpdateProformaTaxControllerParams {
export interface UseUpdateProformaTaxControllerResult {
taxMode: ProformaTaxMode;
hasTaxPercentage: boolean;
defaultTaxPercentage: number | null;
hasRecPercentage: boolean;
hasRetention: boolean;
retentionPercentage: number | null;
defaultRecPercentage: number | null;
hasRetentionPercentage: boolean;
defaultRetentionPercentage: number | null;
usesSingleTax: boolean;
usesPerLineTax: boolean;
enablePerLineTaxes: () => void;
disablePerLineTaxes: () => void;
updateDefaultTaxPercentage: (newTaxPercentage: RetentionPercentageOption) => void;
updateDefaultRecPercentage: (enabled: boolean) => void;
updateDefaultRetentionPercentage: (enabled: boolean) => void;
}
const getRecPercentage = (taxPercentage: number | null | undefined): number | null => {
if (taxPercentage === 21) return 5.2;
if (taxPercentage === 10) return 1.4;
if (taxPercentage === 4) return 0.5;
const resolveRecPercentage = (
enabled: boolean,
taxPercentage: RetentionPercentageOption | null
): number | null => {
if (!enabled || taxPercentage === null) {
return null;
}
return getProformaRecPercentage(taxPercentage as TaxPercentageOption);
};
export const useUpdateProformaTaxController = ({
@ -36,17 +52,28 @@ export const useUpdateProformaTaxController = ({
const { control, getValues, setValue } = form;
const taxMode = useWatch({ control, name: "taxMode" });
const hasRecPercentage = useWatch({ control, name: "hasRecPercentage" }) ?? false;
const hasRetention = useWatch({ control, name: "hasRetention" }) ?? false;
const retentionPercentage = useWatch({ control, name: "retentionPercentage" }) ?? null;
const hasTaxPercentage = useWatch({ control, name: "hasTaxPercentage" }) ?? false;
const defaultTaxPercentage = useWatch({ control, name: "defaultTaxPercentage" }) ?? null;
const hasRecPercentage = useWatch({ control, name: "hasRecPercentage" }) ?? false;
const defaultRecPercentage = useWatch({ control, name: "defaultRecPercentage" }) ?? null;
const hasRetentionPercentage = useWatch({ control, name: "hasRetentionPercentage" }) ?? false;
const defaultRetentionPercentage =
useWatch({ control, name: "defaultRetentionPercentage" }) ?? null;
const hasMountedRef = useRef(false);
useEffect(() => {
if (taxMode !== "single") return;
const currentItems = getValues("items") ?? [];
const recPercentage = hasRecPercentage ? getRecPercentage(defaultTaxPercentage) : null;
const nextRecPercentage = resolveRecPercentage(
hasRecPercentage,
defaultTaxPercentage as RetentionPercentageOption | null
);
const shouldMarkDirty = hasMountedRef.current;
@ -59,8 +86,8 @@ export const useUpdateProformaTaxController = ({
});
}
if (item.recPercentage !== recPercentage) {
setValue(`items.${index}.recPercentage`, recPercentage, {
if (item.recPercentage !== nextRecPercentage) {
setValue(`items.${index}.recPercentage`, nextRecPercentage, {
shouldDirty: shouldMarkDirty,
shouldTouch: false,
shouldValidate: true,
@ -87,18 +114,79 @@ export const useUpdateProformaTaxController = ({
});
}, [setValue]);
const updateDefaultTaxPercentage = useCallback(
(newTaxPercentage: RetentionPercentageOption): void => {
setValue("defaultTaxPercentage", newTaxPercentage, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
});
const isRecPercentageEnabled = getValues("hasRecPercentage");
if (isRecPercentageEnabled) {
setValue(
"defaultRecPercentage",
getProformaRecPercentage(newTaxPercentage as TaxPercentageOption),
{
shouldDirty: true,
shouldValidate: true,
}
);
}
},
[getValues, setValue]
);
const updateDefaultRecPercentage = useCallback(
(enabled: boolean): void => {
setValue("hasRecPercentage", enabled, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
});
const taxPercentage = getValues("defaultTaxPercentage") as RetentionPercentageOption | null;
setValue("defaultRecPercentage", resolveRecPercentage(enabled, taxPercentage), {
shouldDirty: true,
shouldValidate: true,
});
},
[getValues, setValue]
);
const updateDefaultRetentionPercentage = useCallback(
(enabled: boolean): void => {
setValue("hasRetentionPercentage", enabled, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
});
},
[setValue]
);
return {
taxMode,
hasTaxPercentage,
defaultTaxPercentage,
hasRecPercentage,
hasRetention,
retentionPercentage,
defaultRecPercentage,
hasRetentionPercentage,
defaultRetentionPercentage,
usesSingleTax: taxMode === "single",
usesPerLineTax: taxMode === "perLine",
enablePerLineTaxes,
disablePerLineTaxes,
updateDefaultTaxPercentage,
updateDefaultRecPercentage,
updateDefaultRetentionPercentage,
};
};

View File

@ -15,22 +15,22 @@ export interface UseUpdateProformaTotalsControllerResult {
export const useUpdateProformaTotalsController = ({
form,
}: UseUpdateProformaTotalsControllerParams): UseUpdateProformaTotalsControllerResult => {
const { control, getValues } = form;
const { control } = form;
const items = useWatch({ control, name: "items" });
const globalDiscountPercentage = useWatch({ control, name: "globalDiscountPercentage" });
const hasRetention = useWatch({ control, name: "hasRetention" });
const retentionPercentage = useWatch({ control, name: "retentionPercentage" });
const items = useWatch({ control, name: "items" });
const hasRetentionPercentage = useWatch({ control, name: "hasRetentionPercentage" });
const retentionPercentage = useWatch({ control, name: "defaultRetentionPercentage" });
const totals = useMemo(() => {
return calculateProformaTotals({
...getValues(),
items,
globalDiscountPercentage,
hasRetention,
retentionPercentage,
items: items ?? [],
hasRetentionPercentage: hasRetentionPercentage ?? false,
retentionPercentage: retentionPercentage ?? null,
});
}, [items, globalDiscountPercentage, hasRetention, retentionPercentage]);
}, [globalDiscountPercentage, items, hasRetentionPercentage, retentionPercentage]);
return { totals };
};

View File

@ -1,35 +0,0 @@
export interface CommercialDocumentLineInput {
quantity: number | null;
unitAmount: number | null;
itemDiscountPercentage: number | null;
taxPercentage: number | null;
recPercentage: number | null;
}
export interface CommercialDocumentLineAmounts {
grossAmount: number;
itemDiscountAmount: number;
taxableBaseBeforeGlobalDiscount: number;
}
export interface CommercialDocumentTaxBreakdownLine {
taxPercentage: number;
taxableBase: number;
taxAmount: number;
}
export interface CommercialDocumentTotals {
subtotalBeforeDiscounts: number;
lineDiscountTotal: number;
globalDiscountPercentage: number;
globalDiscountAmount: number;
taxableBase: number;
taxBreakdown: CommercialDocumentTaxBreakdownLine[];
taxTotal: number;
total: number;
}

View File

@ -1,4 +1,4 @@
export * from "./commercial-document-calculation.entity";
export * from "./proforma-calculation.entity";
export * from "./proforma-item-update-form.entity";
export * from "./proforma-item-update-form.schema";
export * from "./proforma-item-update-patch.entity";

View File

@ -0,0 +1,49 @@
export interface ProformaLineInput {
quantity: number | null;
unitAmount: number | null;
itemDiscountPercentage: number | null;
globalDiscountPercentage: number | null;
taxPercentage: number | null;
recPercentage: number | null;
}
export interface ProformaLineTotal {
subtotalBeforeDiscounts: number;
itemDiscountAmount: number;
subtotalBeforeGlobalDiscount: number;
globalDiscountAmount: number;
totalDiscountAmount: number;
}
export interface ProformaTaxBreakdown {
taxPercentage: number;
taxableBase: number;
taxAmount: number;
recPercentage: number | null;
recAmount: number;
}
export interface ProformaTotals {
subtotalBeforeDiscounts: number;
lineDiscountTotal: number;
globalDiscountPercentage: number;
globalDiscountAmount: number;
totalDiscountAmount: number;
taxableBase: number;
taxBreakdown: ProformaTaxBreakdown[];
taxTotal: number;
recTotal: number;
retentionPercentage: number | null;
retentionAmount: number;
total: number;
}

View File

@ -36,13 +36,17 @@ export interface ProformaUpdateForm {
taxMode: ProformaTaxMode;
taxRegimeCode: string | null;
hasTaxPercentage: boolean;
defaultTaxPercentage: number | null;
hasRecPercentage: boolean;
hasRetention: boolean;
retentionPercentage: number | null;
defaultRecPercentage: number | null;
paymentMethod: string;
hasRetentionPercentage: boolean;
defaultRetentionPercentage: number | null;
paymentMethodId: string | null;
items: ProformaItemUpdateForm[];
}

View File

@ -1,3 +1,4 @@
import { PercentageHelper } from "@repo/rdx-utils";
import { z } from "zod/v4";
import { ProformaItemUpdateFormSchema } from "./proforma-item-update-form.schema";
@ -16,7 +17,8 @@ import { ProformaItemUpdateFormSchema } from "./proforma-item-update-form.schema
* - sin detalles impuestos por el widget
*/
export const ProformaUpdateFormSchema = z.object({
export const ProformaUpdateFormSchema = z
.object({
series: z.string(),
invoiceDate: z.string().min(1),
@ -35,15 +37,35 @@ export const ProformaUpdateFormSchema = z.object({
taxMode: z.enum(["single", "perLine"]),
taxRegimeCode: z.string(),
hasTaxPercentage: z.boolean(),
defaultTaxPercentage: z.number().nullable(),
hasRecPercentage: z.boolean(),
hasRetention: z.boolean(),
retentionPercentage: z.number().nullable(),
defaultRecPercentage: z.number().nullable(),
paymentMethod: z.string(),
hasRetentionPercentage: z.boolean(),
defaultRetentionPercentage: z.number().nullable(),
paymentMethodId: z.string().nullable(),
items: z.array(ProformaItemUpdateFormSchema).min(1),
});
})
.refine(
(formValues) => {
if (
formValues.hasRetentionPercentage &&
!PercentageHelper.hasPositivePercentage(formValues.defaultRetentionPercentage)
) {
return false;
}
return true;
},
{
message: "Retention percentage is required when retention percentage is enabled",
path: ["defaultRetentionPercentage"],
}
);
export type ProformaUpdateFormSchemaType = z.infer<typeof ProformaUpdateFormSchema>;

View File

@ -1,27 +1,17 @@
import type { ProformaHeaderTotals } from "./proforma-calculation.entity";
export interface ProformaTaxBreakdownLine {
taxPercentage: number;
taxableBase: number;
taxAmount: number;
ivaPercentage: number;
ivaAmount: number;
recPercentage: number | null;
equivalenceSurchargeAmount: number;
recAmount: number;
}
export interface ProformaTotals {
subtotalBeforeDiscounts: number;
lineDiscountTotal: number;
globalDiscountPercentage: number;
globalDiscountAmount: number;
taxableBase: number;
taxBreakdown: ProformaTaxBreakdownLine[];
taxTotal: number;
equivalenceSurchargeTotal: number;
export interface ProformaTotals extends ProformaHeaderTotals {
recPercentage: number | null;
recAmount: number;
retentionPercentage: number | null;
retentionAmount: number;
total: number;
}

View File

@ -1,5 +1,6 @@
import {
AmountField,
CheckboxField,
LineDescriptionField,
PercentageField,
QuantityField,
@ -63,6 +64,12 @@ export const ProformaLineEditor = ({
const { t } = useTranslation();
const columns: LineEditorColumn<ProformaItemField>[] = [
{
id: "isValued",
header: t("form_fields.items.description.is_valued", "¿Valorado?"),
headClassName: "w-[100px] text-right",
cell: ({ index }) => <CheckboxField name={`items.${index}.isValued`} />,
},
{
id: "description",
header: t("form_fields.items.description.label", "Descripción"),

View File

@ -1,7 +1,9 @@
import { FormSectionCard, FormSectionGrid, PercentageField } from "@repo/rdx-ui/components";
import { FormSectionCard, FormSectionGrid } from "@repo/rdx-ui/components";
import { MoneyHelper, PercentageHelper } from "@repo/rdx-utils";
import { Separator } from "@repo/shadcn-ui/components";
import { Card, CardContent, CardHeader, CardTitle, Separator } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { CurrencyIcon } from "lucide-react";
import type { ReactNode } from "react";
import { useTranslation } from "../../../../i18n";
import type { ProformaTotals } from "../../entities";
@ -10,25 +12,22 @@ interface ProformaTotalsSummaryProps {
totals: ProformaTotals;
currency?: string;
showEquivalenceSurcharge?: boolean;
showRec?: boolean;
showRetention?: boolean;
disabled?: boolean;
readOnly?: boolean;
globalDiscountField?: ReactNode;
disabled?: boolean;
className?: string;
}
export const ProformaTotalsSummary = ({
totals,
currency = "EUR",
showEquivalenceSurcharge = false,
showRec = false,
showRetention = false,
globalDiscountField,
disabled = false,
readOnly = false,
className,
}: ProformaTotalsSummaryProps) => {
const { t } = useTranslation();
@ -41,6 +40,13 @@ export const ProformaTotalsSummary = ({
return PercentageHelper.formatPercent(value);
};
const retentionLabel =
totals.retentionPercentage === null
? t("proformas.update.totals.retentionAmount", "Total retenciones")
: `${t("proformas.update.totals.retentionAmount", "Total retenciones")} ${formatPercent(
totals.retentionPercentage
)}`;
return (
<FormSectionCard
className={className}
@ -49,40 +55,42 @@ export const ProformaTotalsSummary = ({
icon={<CurrencyIcon className="size-5" />}
title={t("proformas.update.totals.title", "Totales")}
>
<FormSectionGrid className="md:grid-cols-1 space-y-5">
<FormSectionGrid className="gap-5 md:grid-cols-1">
<TotalsRow
label={t("proformas.update.totals.subtotalBeforeDiscounts", "Subtotal sin descuentos")}
label={t("proformas.update.totals.subtotalBeforeDiscounts", "Total de las líneas")}
value={formatMoney(totals.subtotalBeforeDiscounts)}
/>
<section className="space-y-3 rounded-lg border bg-background p-4">
<h3 className="text-sm font-medium">
{t("proformas.update.totals.discounts", "Descuentos")}
</h3>
<Card
className={cn(disabled ? "bg-muted text-muted-foreground" : "bg-primary/10 text-primary")}
>
<CardHeader>
<CardTitle>{t("proformas.update.totals.discounts", "Descuentos")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<TotalsRow
label={t("proformas.update.totals.lineDiscountTotal", "Descuento en líneas")}
value={`-${formatMoney(totals.lineDiscountTotal)}`}
/>
<div className="grid grid-cols-[minmax(0,1fr)_10rem] items-end gap-3">
<PercentageField
disabled={disabled}
label={t("proformas.update.totals.globalDiscountPercentage", "Descuento global")}
name="globalDiscountPercentage"
readOnly={readOnly}
{globalDiscountField ? <div>{globalDiscountField}</div> : null}
<TotalsRow
description={formatPercent(totals.globalDiscountPercentage)}
label={t("proformas.update.totals.globalDiscountAmount", "Descuento global")}
value={`-${formatMoney(totals.globalDiscountAmount)}`}
/>
<div className="pb-1 text-right">
<div className="text-xs text-muted-foreground">
{t("proformas.update.totals.globalDiscountAmount", "Importe descuento global")}
</div>
<div className="font-mono text-sm tabular-nums">
-{formatMoney(totals.globalDiscountAmount)}
</div>
</div>
</div>
</section>
<Separator className="my-4" />
<TotalsRow
label={t("proformas.update.totals.totalDiscountAmount", "Total de descuentos")}
strong
value={`-${formatMoney(totals.totalDiscountAmount)}`}
/>
</CardContent>
</Card>
<TotalsRow
label={t("proformas.update.totals.taxableBase", "Base imponible")}
@ -90,71 +98,68 @@ export const ProformaTotalsSummary = ({
value={formatMoney(totals.taxableBase)}
/>
<section className="space-y-3 rounded-lg border bg-background p-4">
<h3 className="text-sm font-medium">{t("proformas.update.totals.taxes", "Impuestos")}</h3>
<Card className="bg-muted text-muted-foreground">
<CardHeader>
<CardTitle>{t("proformas.update.totals.taxes", "Impuestos")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{totals.taxBreakdown.length > 0 ? (
totals.taxBreakdown.map((tax) => (
<div className="space-y-2" key={tax.taxPercentage}>
totals.taxBreakdown.map((tax) => {
const key = `${tax.taxPercentage}:${tax.recPercentage ?? "none"}`;
return (
<div className="space-y-2" key={key}>
<TotalsRow
description={formatMoney(tax.taxableBase)}
label={`${t("proformas.update.totals.taxPercentage", "IVA")} ${formatPercent(
tax.taxPercentage
)}`}
label={`${t(
"proformas.update.totals.taxPercentage",
"Impuesto"
)} ${formatPercent(tax.taxPercentage)}`}
value={formatMoney(tax.taxAmount)}
/>
{showEquivalenceSurcharge && tax.recPercentage !== null ? (
{showRec && tax.recPercentage !== null ? (
<TotalsRow
description={formatMoney(tax.taxableBase)}
label={`${t(
"proformas.update.totals.equivalenceSurcharge",
"proformas.update.totals.recPercentage",
"Recargo equivalencia"
)} ${formatPercent(tax.recPercentage)}`}
value={formatMoney(tax.equivalenceSurchargeAmount)}
value={formatMoney(tax.recAmount)}
/>
) : null}
</div>
))
);
})
) : (
<p className="text-sm text-muted-foreground">
{t("proformas.update.totals.noTaxes", "Sin impuestos aplicados")}
</p>
)}
</CardContent>
</Card>
<Separator />
<TotalsRow
label={t("proformas.update.totals.taxTotal", "Total IVA")}
label={t("proformas.update.totals.taxTotal", "Total impuestos")}
strong
value={formatMoney(totals.taxTotal)}
/>
{showEquivalenceSurcharge ? (
{showRec ? (
<TotalsRow
label={t(
"proformas.update.totals.equivalenceSurchargeTotal",
"Total recargo equivalencia"
)}
label={t("proformas.update.totals.recTotal", "Total recargo equivalencia")}
strong
value={formatMoney(totals.equivalenceSurchargeTotal)}
value={formatMoney(totals.recTotal)}
/>
) : null}
</section>
{showRetention ? (
<section className="space-y-3 rounded-lg border bg-background p-4">
<h3 className="text-sm font-medium">
{t("proformas.update.totals.retention", "Retención")}
</h3>
<TotalsRow
label={`${t("proformas.update.totals.retentionPercentage", "IRPF")} ${formatPercent(
totals.retentionPercentage ?? 0
)}`}
label={retentionLabel}
strong
value={`-${formatMoney(totals.retentionAmount)}`}
/>
</section>
) : null}
<Separator />
@ -163,6 +168,7 @@ export const ProformaTotalsSummary = ({
<span className="text-sm font-semibold">
{t("proformas.update.totals.total", "Total factura")}
</span>
<span className="font-mono text-xl font-bold tabular-nums">
{formatMoney(totals.total)}
</span>
@ -175,15 +181,21 @@ export const ProformaTotalsSummary = ({
interface TotalsRowProps {
label: string;
value: string;
className?: string;
description?: string;
strong?: boolean;
}
const TotalsRow = ({ label, value, description, strong = false }: TotalsRowProps) => {
const TotalsRow = ({ label, value, description, className, strong = false }: TotalsRowProps) => {
return (
<div className="flex items-start justify-between gap-4">
<div>
<div className={strong ? "text-sm font-semibold" : "text-sm text-muted-foreground"}>
<div className={cn("flex items-start justify-between gap-4", className)}>
<div className="min-w-0">
<div
className={cn(
"text-sm",
strong ? "font-bold text-foreground" : "font-medium text-muted-foreground"
)}
>
{label}
</div>
@ -191,9 +203,10 @@ const TotalsRow = ({ label, value, description, strong = false }: TotalsRowProps
</div>
<div
className={
strong ? "font-mono text-sm font-semibold tabular-nums" : "font-mono text-sm tabular-nums"
}
className={cn(
"shrink-0 text-right font-mono tabular-nums",
strong ? "text-base font-bold" : "text-sm font-medium"
)}
>
{value}
</div>

View File

@ -19,7 +19,11 @@ import {
} from "@repo/shadcn-ui/components";
import { FileText } from "lucide-react";
import { useTranslation } from "../../../../i18n";
export const ProformaTaxesCard = () => {
const { t } = useTranslation();
return (
<Card className="shadow">
<CardHeader className="px-6 pb-7 pt-3">
@ -79,13 +83,16 @@ export const ProformaTaxesCard = () => {
className="cursor-pointer font-medium text-primary-700"
htmlFor="same-vat"
>
Aplicar el mismo IVA a todas las líneas de la proforma
{t(
"proformas.update.taxes.disable_per_line",
"Aplicar el mismo IVA a todas las líneas de la proforma"
)}
</FieldLabel>
</Field>
<Field className="w-32">
<FieldLabel className="text-primary-700" htmlFor="default-vat">
IVA por defecto
{t("form_fields.proformas.default_tax_percentage.label", "IVA por defecto")}
</FieldLabel>
<Select defaultValue="21">

View File

@ -1,6 +1,7 @@
// modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-editor.tsx
import type { CustomerSelectionOption } from "@erp/customers";
import { PercentageField } from "@repo/rdx-ui/components";
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
import { Button } from "@repo/shadcn-ui/components";
@ -75,21 +76,29 @@ export const ProformaUpdateEditorForm = ({
<ProformaUpdateItemsEditor disabled={isSubmitting} itemsCtrl={itemsCtrl} taxCtrl={taxCtrl} />
<div className="grid grid-cols-1">
<ProformaTaxesCard />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-12">
<ProformaUpdateTaxEditor className="md:col-span-6" taxCtrl={taxCtrl} />
<ProformaTotalsSummary
className="md:col-span-6"
currency={currencyCode}
globalDiscountField={
<PercentageField
disabled={isSubmitting}
showEquivalenceSurcharge={taxCtrl.hasRecPercentage}
showRetention={taxCtrl.hasRetention}
inputClassName="bg-background"
label={t("proformas.update.totals.globalDiscountPercentage", "Descuento global")}
name="globalDiscountPercentage"
/>
}
showRec={taxCtrl.hasRecPercentage}
showRetention={taxCtrl.hasRetentionPercentage}
totals={totalsCtrl.totals}
/>
</div>
<div className="grid grid-cols-1">
<ProformaTaxesCard />
</div>
<div className="flex flex-col-reverse gap-3 border-t pt-4 sm:flex-row sm:justify-end">
<Button disabled={isSubmitting} onClick={onReset} type="button" variant="outline">
{t("common.reset", "Restablecer")}

View File

@ -1,25 +1,29 @@
import {
CheckboxField,
FormSectionCard,
FormSectionGrid,
PercentageField,
SelectField,
SwitchField,
} from "@repo/rdx-ui/components";
import { PercentageHelper } from "@repo/rdx-utils";
import {
Card,
CardAction,
CardContent,
Checkbox,
CardDescription,
CardHeader,
CardTitle,
Field,
FieldDescription,
FieldLegend,
FieldSeparator,
FieldSet,
Label,
Switch,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { ReceiptTextIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
import { getProformaRetentionOptions, getProformaTaxOptions } from "../../../shared";
import type { UseUpdateProformaTaxControllerResult } from "../../controllers";
interface ProformaUpdateTaxEditorProps {
@ -130,41 +134,38 @@ export const ProformaUpdateTaxEditor = ({
<Card
className={cn(disabled ? "bg-muted text-muted-foreground" : "bg-primary/10 text-primary")}
>
<CardContent>
<FieldSet>
<FieldLegend className="font-semibold">Configuración de IVA</FieldLegend>
<FieldDescription>
<CardHeader>
<CardTitle>
{" "}
{t(
"proformas.update.taxes.disable_per_line",
"Mismo IVA en todas las líneas de la proforma"
)}
</CardTitle>
<CardDescription>
Puedes usar un tipo único para todos las líneas de detalle o permitir que cada línea
tenga su propio IVA.
</FieldDescription>
<FormSectionGrid>
<Field className="md:col-span-12 md:col-start-1" orientation="horizontal">
<Checkbox
</CardDescription>
<CardAction>
<Switch
checked={taxCtrl.usesSingleTax}
className="not-disabled:cursor-pointer"
disabled={disabled || readOnly}
onCheckedChange={(checked) =>
checked ? taxCtrl.disablePerLineTaxes() : taxCtrl.enablePerLineTaxes()
}
/>
<Label htmlFor="terms-checkbox">
{t(
"proformas.update.taxes.disable_per_line",
"Aplicar el mismo IVA a todas las líneas de la proforma"
)}
</Label>
</Field>
</CardAction>
</CardHeader>
<CardContent>
<FieldSet>
<FormSectionGrid>
<SelectField
className="md:col-span-4 md:col-start-1"
deserialize={(value) => (value === null || value === "" ? null : Number(value))}
disabled={disabled}
items={[
{ value: "0", label: "0%" },
{ value: "4", label: "4%" },
{ value: "10", label: "10%" },
{ value: "21", label: "21%" },
]}
inputClassName="bg-background"
items={getProformaTaxOptions()}
label={t("form_fields.proformas.default_tax_percentage.label", "IVA por defecto")}
name="defaultTaxPercentage"
placeholder={t(
@ -175,47 +176,65 @@ export const ProformaUpdateTaxEditor = ({
serialize={(value) => (typeof value === "number" ? String(value) : "")}
/>
</FormSectionGrid>
</FieldSet>{" "}
</CardContent>
</Card>
</FieldSet>
<FieldSeparator className="my-4" />
<FieldSet>
<FieldLegend>Impuestos adicionales</FieldLegend>
<FieldDescription>
Activa opciones fiscales adicionales como recargo de equivalencia o retenciones (IRPF),
según el tipo de cliente y normativa aplicable.
Activa opciones fiscales adicionales como recargo de equivalencia o retenciones
(IRPF), según el tipo de cliente y normativa aplicable.
</FieldDescription>
<FormSectionGrid>
<CheckboxField
<SwitchField
className="md:col-span-12 md:col-start-1"
disabled={disabled}
label={t(
label={
<>
{t(
"form_fields.proformas.has_equivalence_surcharge.label",
"Recargo de equivalencia"
)}
{taxCtrl.hasRecPercentage ? (
<span className="text-sm text-muted-foreground">
{PercentageHelper.formatPercent(taxCtrl.defaultRecPercentage ?? 0)}
</span>
) : null}
</>
}
name="hasRecPercentage"
onCheckedChange={taxCtrl.updateDefaultRecPercentage}
readOnly={readOnly}
/>
<CheckboxField
<SwitchField
className="md:col-span-12 md:col-start-1"
disabled={disabled}
label={t("form_fields.proformas.has_retention.label", "Incluir retención/IRPF")}
name="hasRetention"
name="hasRetentionPercentage"
readOnly={readOnly}
/>
<PercentageField
<SelectField
className="md:col-span-4 md:col-start-1"
disabled={disabled}
deserialize={(value) => (value === null || value === "" ? null : Number(value))}
disabled={disabled || !taxCtrl.hasRetentionPercentage}
inputClassName="bg-background"
items={getProformaRetentionOptions()}
label={t("form_fields.proformas.retention_percentage.label", "Retención")}
name="retentionPercentage"
readOnly={readOnly}
name="defaultRetentionPercentage"
placeholder={t(
"form_fields.proformas.default_tax_percentage.placeholder",
"Selecciona IVA"
)}
readOnly={readOnly || taxCtrl.usesPerLineTax}
serialize={(value) => (typeof value === "number" ? String(value) : "")}
/>
</FormSectionGrid>
</FieldSet>
</CardContent>
</Card>
</FormSectionCard>
);
};

View File

@ -14,7 +14,7 @@ export const buildProformaItemUpdateDefault = (position: number): ProformaItemUp
unitAmount: null,
itemDiscountPercentage: null,
taxPercentage: null,
ivaPercentage: null,
recPercentage: null,
};
};

View File

@ -20,14 +20,17 @@ export const buildProformaUpdateDefault = (): ProformaUpdateForm => {
taxMode: "single",
taxRegimeCode: "01",
hasTaxPercentage: true,
defaultTaxPercentage: 21,
hasRecPercentage: false,
hasRetention: false,
defaultRecPercentage: null,
retentionPercentage: null,
hasRetentionPercentage: false,
defaultRetentionPercentage: 15,
paymentMethod: "",
paymentMethodId: null,
items: [],
};

View File

@ -1,13 +1,36 @@
// update/utils/calculate-proforma-totals.ts
import { mapProformaItemFormToProformaLineInputs } from "../adapters";
import type { ProformaItemUpdateForm, ProformaTotals, ProformaUpdateForm } from "../entities";
import { mapProformaFormToCommercialDocumentLines } from "../adapters";
import type { ProformaTotals, ProformaUpdateForm } from "../entities";
import { calculateProformaTotalsFromLines } from "./calculations";
import { calculateCommercialDocumentTotals } from "./calculations";
export interface CalculateProformaTotalsParams {
globalDiscountPercentage: ProformaUpdateForm["globalDiscountPercentage"];
export const calculateProformaTotals = (form: ProformaUpdateForm): ProformaTotals => {
return calculateCommercialDocumentTotals({
lines: mapProformaFormToCommercialDocumentLines(form),
globalDiscountPercentage: form.globalDiscountPercentage,
items: ProformaItemUpdateForm[];
hasRetentionPercentage: boolean;
retentionPercentage: number | null;
}
export const calculateProformaTotals = ({
globalDiscountPercentage,
items,
hasRetentionPercentage,
retentionPercentage,
}: CalculateProformaTotalsParams): ProformaTotals => {
const lines = mapProformaItemFormToProformaLineInputs(items, {
globalDiscountPercentage,
});
return calculateProformaTotalsFromLines({
lines,
globalDiscountPercentage,
retentionPercentage: hasRetentionPercentage ? retentionPercentage : null,
});
};

View File

@ -0,0 +1,47 @@
import type { ProformaLineInput, ProformaLineTotal } from "../../entities";
import { percentageAmount, roundMoney, toCalculationNumber } from "./money-calculation";
export const calculateProformaLineTotal = (line?: ProformaLineInput): ProformaLineTotal => {
if (!line) {
return {
subtotalBeforeDiscounts: 0,
itemDiscountAmount: 0,
subtotalBeforeGlobalDiscount: 0,
globalDiscountAmount: 0,
totalDiscountAmount: 0,
};
}
const quantity = toCalculationNumber(line.quantity);
const unitAmount = toCalculationNumber(line.unitAmount);
const itemDiscountPercentage = toCalculationNumber(line.itemDiscountPercentage);
const globalDiscountPercentage = toCalculationNumber(line.globalDiscountPercentage);
const subtotalBeforeDiscounts = quantity * unitAmount;
const itemDiscountAmount = percentageAmount(subtotalBeforeDiscounts, itemDiscountPercentage);
const subtotalBeforeGlobalDiscount = subtotalBeforeDiscounts - itemDiscountAmount;
const globalDiscountAmount = percentageAmount(
subtotalBeforeGlobalDiscount,
globalDiscountPercentage
);
const totalDiscountAmount = itemDiscountAmount + globalDiscountAmount;
return {
subtotalBeforeDiscounts: roundMoney(subtotalBeforeDiscounts),
itemDiscountAmount: roundMoney(itemDiscountAmount),
subtotalBeforeGlobalDiscount: roundMoney(subtotalBeforeGlobalDiscount),
globalDiscountAmount: roundMoney(globalDiscountAmount),
totalDiscountAmount: roundMoney(totalDiscountAmount),
};
};

View File

@ -0,0 +1,43 @@
import type { ProformaLineInput } from "../../entities";
import { calculateProformaLineTotal } from "./calculate-proforma-line-total";
import { roundMoney } from "./money-calculation";
export interface ProformaLinesTotals {
subtotalBeforeDiscounts: number;
lineDiscountTotal: number;
taxableBaseBeforeGlobalDiscount: number;
globalDiscountAmount: number;
totalDiscountAmount: number;
}
export const calculateProformaLinesTotals = (lines: ProformaLineInput[]): ProformaLinesTotals => {
return lines.reduce<ProformaLinesTotals>(
(acc, line) => {
const totals = calculateProformaLineTotal(line);
return {
subtotalBeforeDiscounts: roundMoney(
acc.subtotalBeforeDiscounts + totals.subtotalBeforeDiscounts
),
lineDiscountTotal: roundMoney(acc.lineDiscountTotal + totals.itemDiscountAmount),
taxableBaseBeforeGlobalDiscount: roundMoney(
acc.taxableBaseBeforeGlobalDiscount + totals.subtotalBeforeGlobalDiscount
),
globalDiscountAmount: roundMoney(acc.globalDiscountAmount + totals.globalDiscountAmount),
totalDiscountAmount: roundMoney(acc.totalDiscountAmount + totals.totalDiscountAmount),
};
},
{
subtotalBeforeDiscounts: 0,
lineDiscountTotal: 0,
taxableBaseBeforeGlobalDiscount: 0,
globalDiscountAmount: 0,
totalDiscountAmount: 0,
}
);
};

View File

@ -0,0 +1,80 @@
import type { ProformaLineInput, ProformaTaxBreakdown } from "../../entities";
import { calculateProformaLineTotal } from "./calculate-proforma-line-total";
import { calculateProportionalDiscount } from "./calculate-proportional-discount";
import { percentageAmount, roundMoney, toCalculationNumber } from "./money-calculation";
interface CalculateProformaTaxBreakdownParams {
lines: ProformaLineInput[];
taxableBaseBeforeGlobalDiscount: number;
globalDiscountAmount: number;
}
interface TaxBucket {
taxPercentage: number;
recPercentage: number | null;
taxableBase: number;
taxAmount: number;
recAmount: number;
}
const buildTaxBreakdownKey = (taxPercentage: number, recPercentage: number | null): string => {
return `${taxPercentage}:${recPercentage ?? "none"}`;
};
export const calculateProformaTaxBreakdown = ({
lines,
taxableBaseBeforeGlobalDiscount,
globalDiscountAmount,
}: CalculateProformaTaxBreakdownParams): ProformaTaxBreakdown[] => {
const buckets = new Map<string, TaxBucket>();
for (const line of lines) {
const lineTotals = calculateProformaLineTotal(line);
const proportionalGlobalDiscount = calculateProportionalDiscount(
lineTotals.subtotalBeforeGlobalDiscount,
taxableBaseBeforeGlobalDiscount,
globalDiscountAmount
);
const lineTaxableBase = lineTotals.subtotalBeforeGlobalDiscount - proportionalGlobalDiscount;
const taxPercentage = toCalculationNumber(line.taxPercentage);
const recPercentage =
line.recPercentage === null ? null : toCalculationNumber(line.recPercentage);
const taxAmount = percentageAmount(lineTaxableBase, taxPercentage);
const recAmount = recPercentage === null ? 0 : percentageAmount(lineTaxableBase, recPercentage);
const key = buildTaxBreakdownKey(taxPercentage, recPercentage);
const current = buckets.get(key);
buckets.set(key, {
taxPercentage,
recPercentage,
taxableBase: (current?.taxableBase ?? 0) + lineTaxableBase,
taxAmount: (current?.taxAmount ?? 0) + taxAmount,
recAmount: (current?.recAmount ?? 0) + recAmount,
});
}
return Array.from(buckets.values())
.sort((a, b) => a.taxPercentage - b.taxPercentage)
.map((bucket) => ({
taxPercentage: bucket.taxPercentage,
taxableBase: roundMoney(bucket.taxableBase),
taxAmount: roundMoney(bucket.taxAmount),
recPercentage: bucket.recPercentage,
recAmount: roundMoney(bucket.recAmount),
}));
};

View File

@ -0,0 +1,76 @@
import type { ProformaLineInput, ProformaTotals } from "../../entities";
import { calculateProformaLinesTotals } from "./calculate-proforma-lines-totals";
import { calculateProformaTaxBreakdown } from "./calculate-proforma-tax-breakdown";
import { percentageAmount, roundMoney, toCalculationNumber } from "./money-calculation";
interface CalculateProformaTotalsFromLinesParams {
lines: ProformaLineInput[];
globalDiscountPercentage: number | null;
retentionPercentage: number | null;
}
export const calculateProformaTotalsFromLines = ({
lines,
globalDiscountPercentage,
retentionPercentage,
}: CalculateProformaTotalsFromLinesParams): ProformaTotals => {
const normalizedGlobalDiscountPercentage = toCalculationNumber(globalDiscountPercentage);
const lineTotals = calculateProformaLinesTotals(lines);
const globalDiscountAmount = percentageAmount(
lineTotals.taxableBaseBeforeGlobalDiscount,
normalizedGlobalDiscountPercentage
);
const taxableBase = lineTotals.taxableBaseBeforeGlobalDiscount - globalDiscountAmount;
const taxBreakdown = calculateProformaTaxBreakdown({
lines,
taxableBaseBeforeGlobalDiscount: lineTotals.taxableBaseBeforeGlobalDiscount,
globalDiscountAmount,
});
const taxTotal = taxBreakdown.reduce((acc, item) => acc + item.taxAmount, 0);
const recTotal = taxBreakdown.reduce((acc, item) => acc + item.recAmount, 0);
const normalizedRetentionPercentage =
retentionPercentage === null ? null : toCalculationNumber(retentionPercentage);
const retentionAmount =
normalizedRetentionPercentage === null
? 0
: percentageAmount(taxableBase, normalizedRetentionPercentage);
return {
subtotalBeforeDiscounts: roundMoney(lineTotals.subtotalBeforeDiscounts),
lineDiscountTotal: roundMoney(lineTotals.lineDiscountTotal),
globalDiscountPercentage: normalizedGlobalDiscountPercentage,
globalDiscountAmount: roundMoney(globalDiscountAmount),
totalDiscountAmount: roundMoney(lineTotals.lineDiscountTotal + globalDiscountAmount),
taxableBase: roundMoney(taxableBase),
taxBreakdown,
taxTotal: roundMoney(taxTotal),
recTotal: roundMoney(recTotal),
retentionPercentage: normalizedRetentionPercentage,
retentionAmount: roundMoney(retentionAmount),
total: roundMoney(taxableBase + taxTotal + recTotal - retentionAmount),
};
};

View File

@ -1 +1 @@
export * from "./calculate-commercial-document-totals";
export * from "./calculate-proforma-totals-from-lines";

View File

@ -1,5 +1,3 @@
// update/utils/calculations/money-calculation.ts
import { NumberHelper } from "@repo/rdx-utils";
export const toCalculationNumber = (value: number | null | undefined): number => {
@ -10,6 +8,9 @@ export const roundMoney = (value: number): number => {
return NumberHelper.roundToScale(value, 2);
};
export const percentageAmount = (baseAmount: number, percentage: number): number => {
return baseAmount * (percentage / 100);
export const percentageAmount = (
baseAmount: number,
percentage: number | null | undefined
): number => {
return baseAmount * (toCalculationNumber(percentage) / 100);
};

View File

@ -1,6 +0,0 @@
export * from "../issued-invoices/adapters/issued-invoice-resume-dto.adapter";
export * from "../proformas/adapters/proforma-dto.adapter";
export * from "./invoice.form.schema";
export * from "./invoice-resume.form.schema";
export * from "./invoices.api.schema";

View File

@ -1,23 +0,0 @@
export type InvoiceSummaryFormData = CustomerInvoiceSummary & {
subtotal_amount_fmt: string;
subtotal_amount: number;
discount_percentage_fmt: string;
discount_percentage: number;
discount_amount_fmt: string;
discount_amount: number;
taxable_amount_fmt: string;
taxable_amount: number;
taxes_amoun_fmt: string;
taxes_amount: number;
total_amount_fmt: string;
total_amount: number;
};
export type InvoicesPageFormData = CustomerInvoicesPage & {
items: InvoiceSummaryFormData[];
};

View File

@ -1,122 +0,0 @@
import { z } from "zod/v4";
export const InvoiceItemFormSchema = z.object({
description: z.string().max(2000).optional().default(""),
quantity: z.any(), //NumericStringSchema.optional(),
unit_amount: z.any(), //NumericStringSchema.optional(),
subtotal_amount: z.any(), //z.number(),
discount_percentage: z.any(), //NumericStringSchema.optional(),
discount_amount: z.number(),
taxable_amount: z.number(),
tax_codes: z.array(z.string()).default([]),
taxes_amount: z.number(),
total_amount: z.number(),
});
export const InvoiceFormSchema = z.object({
invoice_number: z.string().optional(),
series: z.string().optional(),
invoice_date: z.string().optional(),
operation_date: z.string().optional(),
customer_id: z.string().optional(),
recipient: z
.object({
id: z.string().optional(),
name: z.string().optional(),
tin: z.string().optional(),
street: z.string().optional(),
street2: z.string().optional(),
city: z.string().optional(),
province: z.string().optional(),
postal_code: z.string().optional(),
country: z.string().optional(),
})
.optional(),
reference: z.string().optional(),
description: z.string().optional(),
notes: z.string().optional(),
language_code: z
.string({
error: "El idioma es obligatorio",
})
.min(1, "Debe indicar un idioma")
.toUpperCase() // asegura mayúsculas
.default("es"),
currency_code: z
.string({
error: "La moneda es obligatoria",
})
.min(1, "La moneda no puede estar vacía")
.toUpperCase() // asegura mayúsculas
.default("EUR"),
taxes: z
.array(
z.object({
tax_code: z.string(),
tax_label: z.string(),
taxable_amount: z.number(),
taxes_amount: z.number(),
})
)
.optional(),
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(),
taxes_amount: z.number(),
total_amount: z.number(),
});
export type InvoiceFormData = z.infer<typeof InvoiceFormSchema>;
export type InvoiceItemFormData = z.infer<typeof InvoiceItemFormSchema>;
export const defaultCustomerInvoiceItemFormData: InvoiceItemFormData = {
description: "",
quantity: "",
unit_amount: "",
subtotal_amount: 0,
discount_percentage: "",
discount_amount: 0,
taxable_amount: 0,
tax_codes: ["iva_21"],
taxes_amount: 0,
total_amount: 0,
};
export const defaultCustomerInvoiceFormData: InvoiceFormData = {
invoice_number: "",
series: "",
invoice_date: "",
operation_date: "",
reference: "",
description: "",
notes: "",
language_code: "es",
currency_code: "EUR",
items: [],
subtotal_amount: 0,
items_discount_amount: 0,
discount_amount: 0,
discount_percentage: 0,
taxable_amount: 0,
taxes_amount: 0,
total_amount: 0,
};

View File

@ -1,23 +0,0 @@
import type { ArrayElement } from "@repo/rdx-utils";
import type { z } from "zod/v4";
import { GetIssuedInvoiceByIdResponseSchema, ListIssuedInvoicesResponseSchema } from "../../common";
export const IssuedInvoiceschema = GetIssuedInvoiceByIdResponseSchema.omit({
metadata: true,
});
export type IssueInvoice = z.infer<typeof IssuedInvoiceschema>;
export type IssueInvoiceRecipient = IssueInvoice["recipient"];
export type IssueInvoiceItem = ArrayElement<IssueInvoice["items"]>;
// Resultado de consulta con criteria (paginado, etc.)
export const IssuedInvoicesPageSchema = ListIssuedInvoicesResponseSchema.omit({
metadata: true,
});
//export type PaginatedResponse = z.infer<typeof PaginationSchema>;
//export type CustomerInvoicesPage = z.infer<typeof CustomerInvoicesPageSchema>;
// Ítem simplificado dentro del listado (no toda la entidad)
//export type CustomerInvoiceSummary = Omit<ArrayElement<CustomerInvoicesPage["items"]>, "metadata">;

View File

@ -14,7 +14,7 @@ import { FormFieldLabel } from "./form-field-label.tsx";
type CheckboxFieldProps<TFormValues extends FieldValues> = {
name: FieldPath<TFormValues>;
label: string;
label?: string;
description?: string;
reserveDescriptionSpace?: boolean;

View File

@ -9,5 +9,6 @@ export * from "./multi-select-field.tsx";
export * from "./radio-group-field.tsx";
export * from "./select-field.tsx";
export * from "./semantic-fields/index.ts";
export * from "./switch-field.tsx";
export * from "./text-area-field.tsx";
export * from "./text-field.tsx";

View File

@ -0,0 +1,107 @@
// packages/rdx-ui/src/components/form/switch-field.tsx
import {
Field,
FieldContent,
FieldDescription,
FieldError,
Switch,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import * as React from "react";
import { Controller, type FieldPath, type FieldValues, useFormContext } from "react-hook-form";
import { FormFieldLabel } from "./form-field-label.tsx";
type SwitchFieldProps<TFormValues extends FieldValues> = {
name: FieldPath<TFormValues>;
label?: string | React.ReactNode;
description?: string;
reserveDescriptionSpace?: boolean;
onCheckedChange?: (checked: boolean) => void;
disabled?: boolean;
required?: boolean;
readOnly?: boolean;
orientation?: "vertical" | "horizontal" | "responsive";
className?: string;
inputClassName?: string;
};
export const SwitchField = <TFormValues extends FieldValues>({
name,
label,
description,
reserveDescriptionSpace = false,
onCheckedChange,
disabled = false,
required = false,
readOnly = false,
orientation = "horizontal",
className,
inputClassName,
}: SwitchFieldProps<TFormValues>) => {
const { control, formState } = useFormContext<TFormValues>();
const inputId = React.useId();
const descriptionId = description ? `${inputId}-description` : undefined;
const isDisabled = disabled || readOnly || formState.isSubmitting;
return (
<Controller
control={control}
name={name}
render={({ field, fieldState }) => {
const hasError = Boolean(fieldState.error);
return (
<Field
className={cn("gap-2", className)}
data-invalid={hasError}
orientation={orientation}
>
<Switch
aria-describedby={descriptionId}
aria-invalid={hasError || undefined}
aria-required={required || undefined}
checked={field.value === true}
className={cn(inputClassName)}
disabled={isDisabled}
id={inputId}
name={field.name}
onBlur={field.onBlur}
onCheckedChange={(checked) =>
onCheckedChange ? onCheckedChange(checked) : field.onChange(checked)
}
ref={field.ref}
required={required}
/>
<FieldContent className="gap-1">
<FormFieldLabel className="font-normal" htmlFor={inputId} required={required}>
{label}
</FormFieldLabel>
{description ? (
<FieldDescription id={descriptionId}>{description}</FieldDescription>
) : reserveDescriptionSpace ? (
<div aria-hidden="true" className="min-h-5" />
) : null}
<FieldError errors={[fieldState.error]} />
</FieldContent>
</Field>
);
}}
/>
);
};

View File

@ -10,7 +10,12 @@ const hasPositivePercentage = (value: number | null | undefined): boolean => {
return value !== null && value !== undefined && value > 0;
};
const normalizePercentage = (value: number): number => {
return Math.round(value * 10000) / 10000;
};
export const PercentageHelper = {
formatPercent,
hasPositivePercentage,
normalizePercentage,
};