feat(tax-definitions): enhance tax definitions settings with new filters and pagination

- Updated Spanish localization for tax definitions, adding new fields and improving structure.
- Enhanced the tax definitions settings page controller to support family and scope filters, pagination, and sorting.
- Introduced a new data table component for displaying tax definitions with improved UI and functionality.
- Added new actions for tax definitions, including create, edit, and delete functionalities.
- Updated criteria building for tax definitions to include family and scope filters, along with pagination and sorting options.
- Improved tests for tax definitions to cover new features and ensure proper functionality.
- Refactored recurring proforma controllers to utilize new query field guards for better validation.
This commit is contained in:
David Arranz 2026-09-12 14:27:54 +02:00
parent 52335dcbd7
commit 9c7dfc8645
20 changed files with 947 additions and 116 deletions

View File

@ -1,11 +1,20 @@
import type { TaxDefinitionCode } from "@erp/catalogs/api/domain"; import type { TaxDefinitionCode, TaxFamily, TaxRate, TaxScope } from "@erp/catalogs/api/domain";
import type { TextValue, UniqueID } from "@repo/rdx-ddd"; import type { TextValue, UniqueID } from "@repo/rdx-ddd";
import type { Maybe } from "@repo/rdx-utils";
export type TaxDefinitionSummary = { export type TaxDefinitionSummary = {
id: UniqueID; id: UniqueID;
companyId: UniqueID; companyId: UniqueID;
code: TaxDefinitionCode; code: TaxDefinitionCode;
name: TextValue; name: TextValue;
description: Maybe<TextValue>;
rate: TaxRate;
taxFamily: TaxFamily;
jurisdictionCountryCode: string;
jurisdictionRegionCode: Maybe<string>;
taxScope: TaxScope;
validFrom: Maybe<string>;
validTo: Maybe<string>;
isActive: boolean; isActive: boolean;
isSystem: boolean; isSystem: boolean;
}; };

View File

@ -13,6 +13,29 @@ export class TaxDefinitionSummarySnapshotBuilder implements ITaxDefinitionSummar
company_id: src.companyId.toString(), company_id: src.companyId.toString(),
code: src.code.toPrimitive(), code: src.code.toPrimitive(),
name: src.name.toPrimitive(), name: src.name.toPrimitive(),
description: src.description.match(
(value) => value.toPrimitive(),
() => null
),
rate: {
value: String(src.rate.toPrimitive().value),
scale: String(src.rate.toPrimitive().scale),
},
tax_family: src.taxFamily.toPrimitive(),
jurisdiction_country_code: src.jurisdictionCountryCode,
jurisdiction_region_code: src.jurisdictionRegionCode.match(
(value) => value,
() => null
),
tax_scope: src.taxScope.toPrimitive(),
valid_from: src.validFrom.match(
(value) => value,
() => null
),
valid_to: src.validTo.match(
(value) => value,
() => null
),
is_active: src.isActive, is_active: src.isActive,
is_system: src.isSystem, is_system: src.isSystem,
}; };

View File

@ -5,11 +5,12 @@ import {
ValidationErrorCollection, ValidationErrorCollection,
type ValidationErrorDetail, type ValidationErrorDetail,
extractOrPushError, extractOrPushError,
maybeFromNullableResult,
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import type { TaxDefinitionSummary } from "../../../../../application"; import type { TaxDefinitionSummary } from "../../../../../application";
import { TaxDefinitionCode } from "../../../../../domain"; import { TaxDefinitionCode, TaxFamily, TaxRate, TaxScope } from "../../../../../domain";
import type { TaxDefinitionModel } from "../models"; import type { TaxDefinitionModel } from "../models";
export class SequelizeTaxDefinitionSummaryMapper extends SequelizeQueryMapper< export class SequelizeTaxDefinitionSummaryMapper extends SequelizeQueryMapper<
@ -26,6 +27,33 @@ export class SequelizeTaxDefinitionSummaryMapper extends SequelizeQueryMapper<
const id = extractOrPushError(UniqueID.create(raw.id), "id", errors); const id = extractOrPushError(UniqueID.create(raw.id), "id", errors);
const code = extractOrPushError(TaxDefinitionCode.create(raw.code), "code", errors); const code = extractOrPushError(TaxDefinitionCode.create(raw.code), "code", errors);
const name = extractOrPushError(TextValue.create(raw.name), "name", errors); const name = extractOrPushError(TextValue.create(raw.name), "name", errors);
const description = extractOrPushError(
maybeFromNullableResult(raw.description, (value) => TextValue.create(value)),
"description",
errors
);
const rate = extractOrPushError(
TaxRate.create({ value: raw.rate_value, scale: raw.rate_scale }),
"rate",
errors
);
const taxFamily = extractOrPushError(TaxFamily.create(raw.tax_family), "tax_family", errors);
const taxScope = extractOrPushError(TaxScope.create(raw.tax_scope), "tax_scope", errors);
const jurisdictionRegionCode = extractOrPushError(
maybeFromNullableResult(raw.jurisdiction_region_code, (value) => Result.ok(value)),
"jurisdiction_region_code",
errors
);
const validFrom = extractOrPushError(
maybeFromNullableResult(raw.valid_from, (value) => Result.ok(String(value).slice(0, 10))),
"valid_from",
errors
);
const validTo = extractOrPushError(
maybeFromNullableResult(raw.valid_to, (value) => Result.ok(String(value).slice(0, 10))),
"valid_to",
errors
);
const isActive = raw.is_active; const isActive = raw.is_active;
const isSystem = raw.is_system; const isSystem = raw.is_system;
@ -41,6 +69,14 @@ export class SequelizeTaxDefinitionSummaryMapper extends SequelizeQueryMapper<
companyId: companyId!, companyId: companyId!,
code: code!, code: code!,
name: name!, name: name!,
description: description!,
rate: rate!,
taxFamily: taxFamily!,
jurisdictionCountryCode: raw.jurisdiction_country_code,
jurisdictionRegionCode: jurisdictionRegionCode!,
taxScope: taxScope!,
validFrom: validFrom!,
validTo: validTo!,
isActive, isActive,
isSystem, isSystem,
}); });

View File

@ -227,6 +227,14 @@ export class SequelizeTaxDefinitionRepository
type: "root", type: "root",
column: "is_active", column: "is_active",
}, },
taxFamily: {
type: "root",
column: "tax_family",
},
taxScope: {
type: "root",
column: "tax_scope",
},
}, },
searchableFields: ["code", "name", "description", "invoice_note"], searchableFields: ["code", "name", "description", "invoice_note"],
sortableFields: ["code", "name"], sortableFields: ["code", "name"],

View File

@ -1,3 +1,4 @@
import { IsoDateSchema, PercentageSchema } from "@erp/core";
import { z } from "zod/v4"; import { z } from "zod/v4";
const TaxDefinitionCodeSchema = z.string().regex(/^[a-z0-9][a-z0-9_]*$/); const TaxDefinitionCodeSchema = z.string().regex(/^[a-z0-9][a-z0-9_]*$/);
@ -7,6 +8,30 @@ export const TaxDefinitionSummarySchema = z.object({
company_id: z.uuid(), company_id: z.uuid(),
code: TaxDefinitionCodeSchema, code: TaxDefinitionCodeSchema,
name: z.string(), name: z.string(),
description: z.string().nullable(),
rate: PercentageSchema,
tax_family: z.enum([
"iva",
"igic",
"ipsi",
"equivalence_surcharge",
"withholding",
"vat",
"gst",
"sales_tax",
"reverse_charge",
"exempt",
"not_subject",
"custom",
]),
jurisdiction_country_code: z.string().regex(/^[A-Z]{2}$/),
jurisdiction_region_code: z
.string()
.regex(/^[A-Z]{2}-[A-Z0-9-]+$/)
.nullable(),
tax_scope: z.enum(["domestic", "intra_eu", "export", "import", "international"]),
valid_from: IsoDateSchema.nullable(),
valid_to: IsoDateSchema.nullable(),
is_active: z.boolean(), is_active: z.boolean(),
is_system: z.boolean(), is_system: z.boolean(),
}); });

View File

@ -397,36 +397,106 @@
"taxDefinitions": { "taxDefinitions": {
"title": "Tax types", "title": "Tax types",
"description": "Manage the tax types available for the company.", "description": "Manage the tax types available for the company.",
"form": { "identity": "Tax identification", "calculation": "Calculation and jurisdiction", "validity": "Validity period", "texts": "Document texts" }, "form": {
"identity": "Tax identification",
"calculation": "Calculation and jurisdiction",
"validity": "Validity period",
"texts": "Document texts"
},
"fields": { "fields": {
"code": "Code", "name": "Name", "rate": "Rate (%)", "code": "Code",
"taxFamily": "Tax family", "calculationBehavior": "Calculation", "name": "Name",
"taxScope": "Scope", "country": "Country", "region": "Region", "rate": "Rate (%)",
"validFrom": "Valid from", "validTo": "Valid to", "taxFamily": "Tax family",
"description": "Description", "invoiceNote": "Invoice note", "calculationBehavior": "Calculation",
"taxScope": "Scope",
"country": "Country",
"region": "Region",
"validFrom": "Valid from",
"validTo": "Valid to",
"description": "Description",
"invoiceNote": "Invoice note",
"allowedSurchargeCodes": "Allowed surcharges" "allowedSurchargeCodes": "Allowed surcharges"
}, },
"options": { "options": {
"family": { "family": {
"iva": "VAT", "igic": "IGIC", "ipsi": "IPSI", "equivalence_surcharge": "Equivalence surcharge", "withholding": "Withholding", "vat": "International VAT", "gst": "Goods and services tax", "sales_tax": "Sales tax", "reverse_charge": "Reverse charge", "exempt": "Exempt", "not_subject": "Not subject", "custom": "Custom" "iva": "VAT",
"igic": "IGIC",
"ipsi": "IPSI",
"equivalence_surcharge": "Equivalence surcharge",
"withholding": "Withholding",
"vat": "International VAT",
"gst": "Goods and services tax",
"sales_tax": "Sales tax",
"reverse_charge": "Reverse charge",
"exempt": "Exempt",
"not_subject": "Not subject",
"custom": "Custom"
}, },
"calculation": { "calculation": {
"additive": "Add to amount", "subtractive": "Subtract from amount", "neutral": "No amount effect" "additive": "Add to amount",
"subtractive": "Subtract from amount",
"neutral": "No amount effect"
}, },
"scope": { "scope": {
"domestic": "Domestic", "intra_eu": "Intra-EU", "export": "Export", "import": "Import", "international": "International" "domestic": "Domestic",
"intra_eu": "Intra-EU",
"export": "Export",
"import": "Import",
"international": "International"
}, },
"country": { "country": {
"ES": "Spain", "FR": "France", "DE": "Germany", "IT": "Italy", "PT": "Portugal", "US": "United States", "MX": "Mexico", "AR": "Argentina" "ES": "Spain",
"FR": "France",
"DE": "Germany",
"IT": "Italy",
"PT": "Portugal",
"US": "United States",
"MX": "Mexico",
"AR": "Argentina"
} }
}, },
"actions": { "create": "New tax type", "save": "Save", "cancel": "Cancel", "edit": "Edit", "enable": "Enable", "disable": "Disable", "delete": "Delete", "more": "More actions", "retry": "Retry", "clearFilters": "Clear filters" }, "actions": {
"create": "New tax type",
"save": "Save",
"cancel": "Cancel",
"edit": "Edit",
"enable": "Enable",
"disable": "Disable",
"delete": "Delete",
"more": "More actions",
"retry": "Retry",
"clearFilters": "Clear filters"
},
"status": { "all": "All", "active": "Active", "inactive": "Inactive", "system": "System" }, "status": { "all": "All", "active": "Active", "inactive": "Inactive", "system": "System" },
"toolbar": { "search": "Search tax types", "status": "Status" }, "toolbar": { "search": "Search by code, name or description...", "status": "Status" },
"filters": {
"taxFamily": "Tax family",
"scope": "Scope",
"status": "Status",
"all": "All",
"allFemale": "All"
},
"columns": {
"code": "Code",
"name": "Name",
"rate": "Rate",
"taxFamily": "Tax family",
"scope": "Scope",
"validity": "Validity",
"status": "Status",
"system": "System",
"actions": "Actions"
},
"system": { "system": "System", "custom": "Custom" },
"validity": { "from": "From", "to": "To" },
"create": { "title": "New tax type", "description": "Create a tax type for this company." }, "create": { "title": "New tax type", "description": "Create a tax type for this company." },
"edit": { "title": "Edit tax type", "description": "Update the selected tax type." }, "edit": { "title": "Edit tax type", "description": "Update the selected tax type." },
"delete": { "title": "Delete tax type", "description": "Delete {{code}} — {{name}}?" }, "delete": { "title": "Delete tax type", "description": "Delete {{code}} — {{name}}?" },
"empty": { "default": "There are no tax types configured.", "filtered": "No results match the selected filters." }, "empty": {
"default": "There are no tax types.",
"filtered": "No tax types match the current filters."
},
"error": { "title": "The settings could not be loaded" } "error": { "title": "The settings could not be loaded" }
} }
} }

View File

@ -397,36 +397,115 @@
"taxDefinitions": { "taxDefinitions": {
"title": "Tipos de impuesto", "title": "Tipos de impuesto",
"description": "Gestiona los tipos de impuesto disponibles para la empresa.", "description": "Gestiona los tipos de impuesto disponibles para la empresa.",
"form": { "identity": "Identificación fiscal", "calculation": "Cálculo y jurisdicción", "validity": "Periodo de vigencia", "texts": "Textos del documento" }, "form": {
"identity": "Identificación fiscal",
"calculation": "Cálculo y jurisdicción",
"validity": "Periodo de vigencia",
"texts": "Textos del documento"
},
"fields": { "fields": {
"code": "Código", "name": "Nombre", "rate": "Tipo (%)", "code": "Código",
"taxFamily": "Familia fiscal", "calculationBehavior": "Cálculo", "name": "Nombre",
"taxScope": "Ámbito", "country": "País", "region": "Región", "rate": "Tipo (%)",
"validFrom": "Válido desde", "validTo": "Válido hasta", "taxFamily": "Familia fiscal",
"description": "Descripción", "invoiceNote": "Nota en factura", "calculationBehavior": "Cálculo",
"taxScope": "Ámbito",
"country": "País",
"region": "Región",
"validFrom": "Válido desde",
"validTo": "Válido hasta",
"description": "Descripción",
"invoiceNote": "Nota en factura",
"allowedSurchargeCodes": "Recargos permitidos" "allowedSurchargeCodes": "Recargos permitidos"
}, },
"options": { "options": {
"family": { "family": {
"iva": "IVA", "igic": "IGIC", "ipsi": "IPSI", "equivalence_surcharge": "Recargo de equivalencia", "withholding": "Retención", "vat": "IVA internacional", "gst": "Impuesto sobre bienes y servicios", "sales_tax": "Impuesto sobre ventas", "reverse_charge": "Inversión del sujeto pasivo", "exempt": "Exento", "not_subject": "No sujeto", "custom": "Personalizado" "iva": "IVA",
"igic": "IGIC",
"ipsi": "IPSI",
"equivalence_surcharge": "Recargo de equivalencia",
"withholding": "Retención",
"vat": "IVA internacional",
"gst": "Impuesto sobre bienes y servicios",
"sales_tax": "Impuesto sobre ventas",
"reverse_charge": "Inversión del sujeto pasivo",
"exempt": "Exento",
"not_subject": "No sujeto",
"custom": "Personalizado"
}, },
"calculation": { "calculation": {
"additive": "Añadir al importe", "subtractive": "Descontar del importe", "neutral": "Sin efecto en el importe" "additive": "Añadir al importe",
"subtractive": "Descontar del importe",
"neutral": "Sin efecto en el importe"
}, },
"scope": { "scope": {
"domestic": "Nacional", "intra_eu": "Intracomunitario", "export": "Exportación", "import": "Importación", "international": "Internacional" "domestic": "Nacional",
"intra_eu": "Intracomunitario",
"export": "Exportación",
"import": "Importación",
"international": "Internacional"
}, },
"country": { "country": {
"ES": "España", "FR": "Francia", "DE": "Alemania", "IT": "Italia", "PT": "Portugal", "US": "Estados Unidos", "MX": "México", "AR": "Argentina" "ES": "España",
"FR": "Francia",
"DE": "Alemania",
"IT": "Italia",
"PT": "Portugal",
"US": "Estados Unidos",
"MX": "México",
"AR": "Argentina"
} }
}, },
"actions": { "create": "Nuevo tipo", "save": "Guardar", "cancel": "Cancelar", "edit": "Editar", "enable": "Activar", "disable": "Desactivar", "delete": "Eliminar", "more": "Más acciones", "retry": "Reintentar", "clearFilters": "Limpiar filtros" }, "actions": {
"create": "Nuevo tipo",
"save": "Guardar",
"cancel": "Cancelar",
"edit": "Editar",
"enable": "Activar",
"disable": "Desactivar",
"delete": "Eliminar",
"more": "Más acciones",
"retry": "Reintentar",
"clearFilters": "Limpiar filtros"
},
"status": { "all": "Todos", "active": "Activo", "inactive": "Inactivo", "system": "Sistema" }, "status": { "all": "Todos", "active": "Activo", "inactive": "Inactivo", "system": "Sistema" },
"toolbar": { "search": "Buscar tipos de impuesto", "status": "Estado" }, "toolbar": { "search": "Buscar por código, nombre o descripción...", "status": "Estado" },
"create": { "title": "Nuevo tipo de impuesto", "description": "Crea un tipo de impuesto para esta empresa." }, "filters": {
"edit": { "title": "Editar tipo de impuesto", "description": "Actualiza el tipo de impuesto seleccionado." }, "taxFamily": "Familia fiscal",
"delete": { "title": "Eliminar tipo de impuesto", "description": "¿Eliminar {{code}} — {{name}}?" }, "scope": "Ámbito",
"empty": { "default": "No hay tipos de impuesto configurados.", "filtered": "No hay resultados para los filtros seleccionados." }, "status": "Estado",
"all": "Todos",
"allFemale": "Todas"
},
"columns": {
"code": "Código",
"name": "Nombre",
"rate": "Tipo",
"taxFamily": "Familia fiscal",
"scope": "Ámbito",
"validity": "Vigencia",
"status": "Estado",
"system": "Sistema",
"actions": "Acciones"
},
"system": { "system": "Sistema", "custom": "Personalizado" },
"validity": { "from": "Desde", "to": "Hasta" },
"create": {
"title": "Nuevo tipo de impuesto",
"description": "Crea un tipo de impuesto para esta empresa."
},
"edit": {
"title": "Editar tipo de impuesto",
"description": "Actualiza el tipo de impuesto seleccionado."
},
"delete": {
"title": "Eliminar tipo de impuesto",
"description": "¿Eliminar {{code}} — {{name}}?"
},
"empty": {
"default": "No hay tipos de impuesto.",
"filtered": "No se han encontrado tipos de impuesto con los filtros actuales."
},
"error": { "title": "No se pudo cargar la configuración" } "error": { "title": "No se pudo cargar la configuración" }
} }
} }

View File

@ -1,8 +1,12 @@
import { useMemo, useState } from "react"; import type { DataTableSort } from "@repo/rdx-ui/components";
import { useCallback, useMemo, useState } from "react";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { useTaxDefinitionListQuery } from "../../../tax-definitions/shared"; import { useTaxDefinitionListQuery } from "../../../tax-definitions/shared";
import { import {
TAX_DEFINITIONS_DEFAULT_SORT,
type TaxDefinitionsFamilyFilter,
type TaxDefinitionsScopeFilter,
type TaxDefinitionsStatusFilter, type TaxDefinitionsStatusFilter,
buildTaxDefinitionsListCriteria, buildTaxDefinitionsListCriteria,
hasTaxDefinitionsListCriteriaApplied, hasTaxDefinitionsListCriteriaApplied,
@ -13,12 +17,26 @@ import { useTaxDefinitionsSettingsController } from "./use-tax-definitions-setti
export const useTaxDefinitionsSettingsPageController = () => { export const useTaxDefinitionsSettingsPageController = () => {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [status, setStatus] = useState<TaxDefinitionsStatusFilter>("all"); const [status, setStatus] = useState<TaxDefinitionsStatusFilter>("all");
const [family, setFamily] = useState<TaxDefinitionsFamilyFilter>("all");
const [scope, setScope] = useState<TaxDefinitionsScopeFilter>("all");
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [sort, setSortState] = useState<DataTableSort>(TAX_DEFINITIONS_DEFAULT_SORT);
const [debouncedSearch] = useDebounce(search, 300); const [debouncedSearch] = useDebounce(search, 300);
const criteria = useMemo( const criteria = useMemo(
() => buildTaxDefinitionsListCriteria({ search: debouncedSearch, status }), () =>
[debouncedSearch, status] buildTaxDefinitionsListCriteria({
search: debouncedSearch,
status,
family,
scope,
pageIndex,
pageSize,
sort,
}),
[debouncedSearch, family, pageIndex, pageSize, scope, sort, status]
); );
const listQuery = useTaxDefinitionListQuery({ criteria }); const listQuery = useTaxDefinitionListQuery({ criteria });
@ -30,8 +48,15 @@ export const useTaxDefinitionsSettingsPageController = () => {
const hasAppliedCriteria = hasTaxDefinitionsListCriteriaApplied({ const hasAppliedCriteria = hasTaxDefinitionsListCriteriaApplied({
search: debouncedSearch, search: debouncedSearch,
status, status,
family,
scope,
}); });
const resetPageAndSet = useCallback(<T>(setter: (value: T) => void, value: T) => {
setter(value);
setPageIndex(0);
}, []);
const retry = () => { const retry = () => {
listQuery.refetch(); listQuery.refetch();
}; };
@ -45,12 +70,25 @@ export const useTaxDefinitionsSettingsPageController = () => {
hasEmptyState: !isInitialLoading && totalItems === 0 && !hasAppliedCriteria, hasEmptyState: !isInitialLoading && totalItems === 0 && !hasAppliedCriteria,
hasAppliedCriteria, hasAppliedCriteria,
search, search,
setSearch, setSearch: (value: string) => resetPageAndSet(setSearch, value),
status, status,
setStatus, setStatus: (value: TaxDefinitionsStatusFilter) => resetPageAndSet(setStatus, value),
family,
setFamily: (value: TaxDefinitionsFamilyFilter) => resetPageAndSet(setFamily, value),
scope,
setScope: (value: TaxDefinitionsScopeFilter) => resetPageAndSet(setScope, value),
pageIndex,
setPageIndex,
pageSize,
setPageSize: (value: number) => resetPageAndSet(setPageSize, value),
sort,
setSort: (value: DataTableSort) => resetPageAndSet(setSortState, value),
clearFilters: () => { clearFilters: () => {
setSearch(""); setSearch("");
setStatus("all"); setStatus("all");
setFamily("all");
setScope("all");
setPageIndex(0);
}, },
criteria, criteria,
pageError: listQuery.isError ? listQuery.error : null, pageError: listQuery.isError ? listQuery.error : null,

View File

@ -1,4 +1,5 @@
export * from "./delete-tax-definition-dialog"; export * from "./delete-tax-definition-dialog";
export * from "./tax-definition-actions";
export * from "./tax-definition-card"; export * from "./tax-definition-card";
export * from "./tax-definition-form-dialog"; export * from "./tax-definition-form-dialog";
export * from "./tax-definitions-empty-state"; export * from "./tax-definitions-empty-state";

View File

@ -1,11 +1,16 @@
import { Card, Skeleton } from "@repo/shadcn-ui/components"; import { SkeletonDataTable } from "@repo/rdx-ui/components";
import { Card, CardContent, CardHeader, Skeleton } from "@repo/shadcn-ui/components";
export const TaxDefinitionsSettingsSkeleton = () => ( export const TaxDefinitionsSettingsSkeleton = () => (
<div className="space-y-3"> <Card>
<Skeleton className="h-20 w-full" /> <CardHeader className="flex-row items-start justify-between gap-4">
{[1, 2, 3].map((id) => ( <div className="space-y-2">
<Card className="p-4" key={id}> <Skeleton className="h-7 w-48" />
<Skeleton className="h-10 w-full" /> <Skeleton className="h-4 w-80" />
</Card> </div>
))} <Skeleton className="h-10 w-32" />
</div> </CardHeader>
<CardContent>
<SkeletonDataTable columns={9} rows={10} showFooter />
</CardContent>
</Card>
); );

View File

@ -1,4 +1,10 @@
import { PageErrorState, SimpleSearchInput } from "@erp/core/components"; import { PageErrorState, SimpleSearchInput } from "@erp/core/components";
import {
DataTable,
DataTableColumnHeader,
type DataTableProps,
RdxBadge,
} from "@repo/rdx-ui/components";
import { import {
Button, Button,
Card, Card,
@ -10,29 +16,264 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react"; import { PlusIcon, RotateCcwIcon } from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
import type {
TaxDefinitionFamily,
TaxDefinitionListRow,
TaxScope,
} from "../../../../tax-definitions/shared";
import { useTaxDefinitionsSettingsPageController } from "../../controllers"; import { useTaxDefinitionsSettingsPageController } from "../../controllers";
import type { TaxDefinitionsStatusFilter } from "../../utils"; import type {
TaxDefinitionsFamilyFilter,
TaxDefinitionsScopeFilter,
TaxDefinitionsStatusFilter,
} from "../../utils";
import { import {
DeleteTaxDefinitionDialog, DeleteTaxDefinitionDialog,
TaxDefinitionCard, TaxDefinitionActions,
TaxDefinitionFormDialog, TaxDefinitionFormDialog,
TaxDefinitionsEmptyState, TaxDefinitionsEmptyState,
TaxDefinitionsSettingsSkeleton, TaxDefinitionsSettingsSkeleton,
} from "../components"; } from "../components";
const TAX_FAMILIES: TaxDefinitionFamily[] = [
"iva",
"igic",
"ipsi",
"equivalence_surcharge",
"withholding",
"vat",
"gst",
"sales_tax",
"reverse_charge",
"exempt",
"not_subject",
"custom",
];
const TAX_SCOPES: TaxScope[] = ["domestic", "intra_eu", "export", "import", "international"];
type TaxDefinitionColumns = DataTableProps<TaxDefinitionListRow, unknown>["columns"];
const formatRate = (rate: number, locale: string) =>
new Intl.NumberFormat(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
rate
);
const formatCivilDate = (value: string, locale: string) => {
const [year, month, day] = value.split("-").map(Number);
return new Intl.DateTimeFormat(locale, {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(new Date(year, month - 1, day));
};
const formatValidity = (row: TaxDefinitionListRow, locale: string, from: string, to: string) => {
if (row.validFrom && row.validTo) {
return `${formatCivilDate(row.validFrom, locale)} – ${formatCivilDate(row.validTo, locale)}`;
}
if (row.validFrom) {
return `${from} ${formatCivilDate(row.validFrom, locale)}`;
}
if (row.validTo) {
return `${to} ${formatCivilDate(row.validTo, locale)}`;
}
return "—";
};
const getFamilyBadgeClassName = (family: TaxDefinitionFamily) => {
if (["iva", "igic", "ipsi", "vat"].includes(family)) {
return "bg-primary/10 text-primary";
}
if (["equivalence_surcharge", "withholding"].includes(family)) {
return "bg-accent text-accent-foreground";
}
return "bg-muted text-muted-foreground";
};
export const TaxDefinitionsSettingsPage = () => { export const TaxDefinitionsSettingsPage = () => {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const pageCtrl = useTaxDefinitionsSettingsPageController(); const pageCtrl = useTaxDefinitionsSettingsPageController();
const featureCtrl = pageCtrl.controller; const featureCtrl = pageCtrl.controller;
const locale = i18n.language === "en" ? "en-US" : "es-ES";
const status = (value: TaxDefinitionsStatusFilter | null) => pageCtrl.setStatus(value ?? "all"); const status = (value: TaxDefinitionsStatusFilter | null) => pageCtrl.setStatus(value ?? "all");
const family = (value: TaxDefinitionsFamilyFilter | null) => pageCtrl.setFamily(value ?? "all");
const scope = (value: TaxDefinitionsScopeFilter | null) => pageCtrl.setScope(value ?? "all");
const columns = useMemo<TaxDefinitionColumns>(
() => [
{
accessorKey: "code",
enableSorting: true,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("settings.taxDefinitions.columns.code")}
/>
),
cell: ({ row }) => <span className="font-mono font-medium">{row.original.code}</span>,
},
{
accessorKey: "name",
enableSorting: true,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("settings.taxDefinitions.columns.name")}
/>
),
cell: ({ row }) => (
<div className="min-w-40 max-w-64 whitespace-normal">
<span className="block font-medium">{row.original.name}</span>
{row.original.description ? (
<span className="block truncate text-sm text-muted-foreground">
{row.original.description}
</span>
) : null}
</div>
),
},
{
id: "rate",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("settings.taxDefinitions.columns.rate")}
/>
),
cell: ({ row }) => (
<RdxBadge className="bg-muted font-medium text-foreground" variant="secondary">
{formatRate(row.original.rate, locale)} %
</RdxBadge>
),
},
{
id: "taxFamily",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("settings.taxDefinitions.columns.taxFamily")}
/>
),
cell: ({ row }) => (
<RdxBadge className={getFamilyBadgeClassName(row.original.taxFamily)} variant="secondary">
{t(`settings.taxDefinitions.options.family.${row.original.taxFamily}`)}
</RdxBadge>
),
},
{
id: "scope",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("settings.taxDefinitions.columns.scope")}
/>
),
cell: ({ row }) => (
<div>
<span className="font-medium">{row.original.jurisdictionCountryCode}</span>
{row.original.jurisdictionRegionCode ? (
<span className="block text-sm text-muted-foreground">
{row.original.jurisdictionRegionCode}
</span>
) : null}
<span className="block text-sm text-muted-foreground">
{t(`settings.taxDefinitions.options.scope.${row.original.taxScope}`)}
</span>
</div>
),
},
{
id: "validity",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("settings.taxDefinitions.columns.validity")}
/>
),
cell: ({ row }) => (
<span className="tabular-nums">
{formatValidity(
row.original,
locale,
t("settings.taxDefinitions.validity.from"),
t("settings.taxDefinitions.validity.to")
)}
</span>
),
},
{
id: "status",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("settings.taxDefinitions.columns.status")}
/>
),
cell: ({ row }) => (
<RdxBadge
className={
row.original.isActive
? "bg-emerald-500/10 text-emerald-700"
: "bg-muted text-muted-foreground"
}
variant="secondary"
>
<span aria-hidden>●</span>{" "}
{t(`settings.taxDefinitions.status.${row.original.isActive ? "active" : "inactive"}`)}
</RdxBadge>
),
},
{
id: "system",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("settings.taxDefinitions.columns.system")}
/>
),
cell: ({ row }) => (
<RdxBadge
className={
row.original.isSystem
? "bg-primary/10 text-primary"
: "bg-accent text-accent-foreground"
}
variant="secondary"
>
{t(`settings.taxDefinitions.system.${row.original.isSystem ? "system" : "custom"}`)}
</RdxBadge>
),
},
{
id: "actions",
enableSorting: false,
meta: {
isActionsColumn: true,
headerClassName: "text-right",
cellClassName: "flex justify-end",
},
header: t("settings.taxDefinitions.columns.actions"),
cell: ({ row }) => (
<TaxDefinitionActions
item={row.original}
onDelete={featureCtrl.openDeleteDialog}
onEdit={featureCtrl.openEditDialog}
onToggle={(item) =>
item.isActive ? featureCtrl.disable(item) : featureCtrl.enable(item)
}
/>
),
},
],
[featureCtrl, locale, t]
);
if (pageCtrl.isInitialLoading) { if (pageCtrl.isInitialLoading) {
return <TaxDefinitionsSettingsSkeleton />; return <TaxDefinitionsSettingsSkeleton />;
} }
if (pageCtrl.pageError) { if (pageCtrl.pageError) {
return ( return (
<PageErrorState <PageErrorState
@ -50,55 +291,119 @@ export const TaxDefinitionsSettingsPage = () => {
return ( return (
<> <>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<h2 className="text-2xl font-semibold">{t("settings.taxDefinitions.title")}</h2> <h2 className="text-2xl font-semibold">{t("settings.taxDefinitions.title")}</h2>
<p>{t("settings.taxDefinitions.description")}</p> <p className="text-muted-foreground">{t("settings.taxDefinitions.description")}</p>
</div> </div>
<Button onClick={featureCtrl.openCreateDialog}> <Button onClick={featureCtrl.openCreateDialog} type="button">
<PlusIcon aria-hidden className="size-4" /> <PlusIcon aria-hidden className="size-4" />
{t("settings.taxDefinitions.actions.create")} {t("settings.taxDefinitions.actions.create")}
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent>
<div className="flex gap-2"> <DataTable
<SimpleSearchInput columns={columns}
onSearchChange={pageCtrl.setSearch} data={pageCtrl.list}
placeholder={t("settings.taxDefinitions.toolbar.search")} enablePagination
value={pageCtrl.search} isFetching={pageCtrl.isBackgroundFetching}
/> manualPagination
<Select onValueChange={status} value={pageCtrl.status}> manualSorting
<SelectTrigger> onPageChange={pageCtrl.setPageIndex}
<SelectValue /> onPageSizeChange={pageCtrl.setPageSize}
</SelectTrigger> onSortChange={pageCtrl.setSort}
<SelectContent> pageIndex={pageCtrl.pageIndex}
<SelectItem value="all">{t("settings.taxDefinitions.status.all")}</SelectItem> pageSize={pageCtrl.pageSize}
<SelectItem value="active">{t("settings.taxDefinitions.status.active")}</SelectItem> readOnly
<SelectItem value="inactive"> sort={pageCtrl.sort}
{t("settings.taxDefinitions.status.inactive")} topContent={
</SelectItem> <div className="flex flex-wrap items-end gap-3">
</SelectContent> <div className="min-w-64 flex-1 basis-72">
</Select> <SimpleSearchInput
</div> onSearchChange={pageCtrl.setSearch}
{pageCtrl.list.length ? ( placeholder={t("settings.taxDefinitions.toolbar.search")}
pageCtrl.list.map((item) => ( value={pageCtrl.search}
<TaxDefinitionCard />
item={item} </div>
key={item.id} <div className="grid min-w-44 gap-1.5 text-sm font-medium">
onDelete={featureCtrl.openDeleteDialog} <span id="tax-definition-family-filter-label">
onEdit={featureCtrl.openEditDialog} {t("settings.taxDefinitions.filters.taxFamily")}
onToggle={(row) => </span>
row.isActive ? featureCtrl.disable(row) : featureCtrl.enable(row) <Select onValueChange={family} value={pageCtrl.family}>
} <SelectTrigger aria-labelledby="tax-definition-family-filter-label">
/> <SelectValue />
)) </SelectTrigger>
) : ( <SelectContent>
<SelectItem value="all">
{t("settings.taxDefinitions.filters.allFemale")}
</SelectItem>
{TAX_FAMILIES.map((item) => (
<SelectItem key={item} value={item}>
{t(`settings.taxDefinitions.options.family.${item}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid min-w-44 gap-1.5 text-sm font-medium">
<span id="tax-definition-scope-filter-label">
{t("settings.taxDefinitions.filters.scope")}
</span>
<Select onValueChange={scope} value={pageCtrl.scope}>
<SelectTrigger aria-labelledby="tax-definition-scope-filter-label">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("settings.taxDefinitions.filters.all")}
</SelectItem>
{TAX_SCOPES.map((item) => (
<SelectItem key={item} value={item}>
{t(`settings.taxDefinitions.options.scope.${item}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid min-w-40 gap-1.5 text-sm font-medium">
<span id="tax-definition-status-filter-label">
{t("settings.taxDefinitions.filters.status")}
</span>
<Select onValueChange={status} value={pageCtrl.status}>
<SelectTrigger aria-labelledby="tax-definition-status-filter-label">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("settings.taxDefinitions.status.all")}</SelectItem>
<SelectItem value="active">
{t("settings.taxDefinitions.status.active")}
</SelectItem>
<SelectItem value="inactive">
{t("settings.taxDefinitions.status.inactive")}
</SelectItem>
</SelectContent>
</Select>
</div>
<Button
disabled={!pageCtrl.hasAppliedCriteria}
onClick={pageCtrl.clearFilters}
type="button"
variant="outline"
>
<RotateCcwIcon aria-hidden className="size-4" />
{t("settings.taxDefinitions.actions.clearFilters")}
</Button>
</div>
}
totalItems={pageCtrl.totalItems}
/>
{pageCtrl.totalItems === 0 ? (
<TaxDefinitionsEmptyState <TaxDefinitionsEmptyState
filtered={pageCtrl.hasAppliedCriteria} filtered={pageCtrl.hasAppliedCriteria}
onClear={pageCtrl.clearFilters} onClear={pageCtrl.clearFilters}
onCreate={featureCtrl.openCreateDialog} onCreate={featureCtrl.openCreateDialog}
/> />
)} ) : null}
</CardContent> </CardContent>
</Card> </Card>
<TaxDefinitionFormDialog <TaxDefinitionFormDialog

View File

@ -3,13 +3,28 @@ import test from "node:test";
import { buildTaxDefinitionsListCriteria } from "./build-tax-definitions-list-criteria"; import { buildTaxDefinitionsListCriteria } from "./build-tax-definitions-list-criteria";
test("construye búsqueda, filtro activo y orden por código", () => { test("construye búsqueda, filtros y orden server-side", () => {
assert.deepEqual(buildTaxDefinitionsListCriteria({ search: " iva ", status: "active" }), { assert.deepEqual(
q: "iva", buildTaxDefinitionsListCriteria({
filters: [{ field: "isActive", operator: "EQUALS", value: "true" }], search: " iva ",
pageNumber: 0, status: "active",
pageSize: 9999, family: "iva",
orderBy: "code", scope: "domestic",
order: "asc", pageIndex: 2,
}); pageSize: 25,
sort: { field: "name", direction: "desc" },
}),
{
q: "iva",
filters: [
{ field: "isActive", operator: "EQUALS", value: "true" },
{ field: "taxFamily", operator: "EQUALS", value: "iva" },
{ field: "taxScope", operator: "EQUALS", value: "domestic" },
],
pageNumber: 2,
pageSize: 25,
orderBy: "name",
order: "desc",
}
);
}); });

View File

@ -1,23 +1,61 @@
import type { CriteriaDTO } from "@erp/core"; import type { CriteriaDTO } from "@erp/core";
import type { DataTableSort } from "@repo/rdx-ui/components";
import type { TaxDefinitionFamily, TaxScope } from "../../../tax-definitions/shared";
export type TaxDefinitionsStatusFilter = "all" | "active" | "inactive"; export type TaxDefinitionsStatusFilter = "all" | "active" | "inactive";
export type TaxDefinitionsFamilyFilter = "all" | TaxDefinitionFamily;
export type TaxDefinitionsScopeFilter = "all" | TaxScope;
const DEFAULT_SORT = { field: "code", direction: "asc" } satisfies DataTableSort;
const SORT_FIELDS = {
code: "code",
name: "name",
} as const;
export const TAX_DEFINITIONS_DEFAULT_SORT = DEFAULT_SORT;
export const buildTaxDefinitionsListCriteria = (params: { export const buildTaxDefinitionsListCriteria = (params: {
search: string; search: string;
status: TaxDefinitionsStatusFilter; status: TaxDefinitionsStatusFilter;
family: TaxDefinitionsFamilyFilter;
scope: TaxDefinitionsScopeFilter;
pageIndex: number;
pageSize: number;
sort: DataTableSort;
}): CriteriaDTO => ({ }): CriteriaDTO => ({
q: params.search.trim() || undefined, q: params.search.trim() || undefined,
filters: filters: [
params.status === "all" ...(params.status === "all"
? undefined ? []
: [{ field: "isActive", operator: "EQUALS", value: String(params.status === "active") }], : [
pageNumber: 0, {
pageSize: 9999, field: "isActive",
orderBy: "code", operator: "EQUALS" as const,
order: "asc", value: String(params.status === "active"),
},
]),
...(params.family === "all"
? []
: [{ field: "taxFamily", operator: "EQUALS" as const, value: params.family }]),
...(params.scope === "all"
? []
: [{ field: "taxScope", operator: "EQUALS" as const, value: params.scope }]),
],
pageNumber: params.pageIndex,
pageSize: params.pageSize,
orderBy: SORT_FIELDS[params.sort.field as keyof typeof SORT_FIELDS] ?? DEFAULT_SORT.field,
order: params.sort.direction,
}); });
export const hasTaxDefinitionsListCriteriaApplied = (params: { export const hasTaxDefinitionsListCriteriaApplied = (params: {
search: string; search: string;
status: TaxDefinitionsStatusFilter; status: TaxDefinitionsStatusFilter;
}) => params.search.trim().length > 0 || params.status !== "all"; family: TaxDefinitionsFamilyFilter;
scope: TaxDefinitionsScopeFilter;
}) =>
params.search.trim().length > 0 ||
params.status !== "all" ||
params.family !== "all" ||
params.scope !== "all";

View File

@ -15,6 +15,14 @@ const system: TaxDefinitionListRow = {
companyId: "company", companyId: "company",
code: "iva_21", code: "iva_21",
name: "IVA", name: "IVA",
description: null,
rate: 21,
taxFamily: "iva",
jurisdictionCountryCode: "ES",
jurisdictionRegionCode: null,
taxScope: "domestic",
validFrom: null,
validTo: null,
isActive: true, isActive: true,
isSystem: true, isSystem: true,
}; };

View File

@ -1,23 +1,101 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import type { ListTaxDefinitionsResponseDTO } from "../../../../common"; import type { ListTaxDefinitionsResponseDTO } from "../../../../common";
import { ListTaxDefinitionsAdapter } from "./list-tax-definitions.adapter"; import { ListTaxDefinitionsAdapter } from "./list-tax-definitions.adapter";
test("adapta paginación y filas ligeras preservando flags", () => { test("adapta paginación y filas ligeras preservando flags", () => {
const dto: ListTaxDefinitionsResponseDTO = { const dto: ListTaxDefinitionsResponseDTO = {
page: 1, per_page: 25, total_pages: 3, total_items: 52, page: 1,
metadata: { entity: "tax_definitions", criteria: { q: "iva", filters: [], orderBy: "code", orderType: "ASC", pageSize: 25, pageNumber: 1 } }, per_page: 25,
total_pages: 3,
total_items: 52,
metadata: {
entity: "tax_definitions",
criteria: {
q: "iva",
filters: [],
orderBy: "code",
orderType: "ASC",
pageSize: 25,
pageNumber: 1,
},
},
items: [ items: [
{ id: "11111111-1111-4111-8111-111111111111", company_id: "22222222-2222-4222-8222-222222222222", code: "iva_21", name: "IVA 21", is_active: true, is_system: false }, {
{ id: "33333333-3333-4333-8333-333333333333", company_id: "22222222-2222-4222-8222-222222222222", code: "iva_10", name: "IVA 10", is_active: false, is_system: true }, id: "11111111-1111-4111-8111-111111111111",
company_id: "22222222-2222-4222-8222-222222222222",
code: "iva_21",
name: "IVA 21",
description: "General",
rate: { value: "2100", scale: "2" },
tax_family: "iva",
jurisdiction_country_code: "ES",
jurisdiction_region_code: null,
tax_scope: "domestic",
valid_from: null,
valid_to: null,
is_active: true,
is_system: false,
},
{
id: "33333333-3333-4333-8333-333333333333",
company_id: "22222222-2222-4222-8222-222222222222",
code: "iva_10",
name: "IVA 10",
description: null,
rate: { value: "1000", scale: "2" },
tax_family: "iva",
jurisdiction_country_code: "ES",
jurisdiction_region_code: null,
tax_scope: "domestic",
valid_from: "2024-01-01",
valid_to: "2025-12-31",
is_active: false,
is_system: true,
},
], ],
}; };
assert.deepEqual(ListTaxDefinitionsAdapter.fromDto(dto), { assert.deepEqual(ListTaxDefinitionsAdapter.fromDto(dto), {
page: 1, perPage: 25, totalPages: 3, totalItems: 52, page: 1,
perPage: 25,
totalPages: 3,
totalItems: 52,
items: [ items: [
{ id: dto.items[0].id, companyId: dto.items[0].company_id, code: "iva_21", name: "IVA 21", isActive: true, isSystem: false }, {
{ id: dto.items[1].id, companyId: dto.items[1].company_id, code: "iva_10", name: "IVA 10", isActive: false, isSystem: true }, id: dto.items[0].id,
companyId: dto.items[0].company_id,
code: "iva_21",
name: "IVA 21",
description: "General",
rate: 21,
taxFamily: "iva",
jurisdictionCountryCode: "ES",
jurisdictionRegionCode: null,
taxScope: "domestic",
validFrom: null,
validTo: null,
isActive: true,
isSystem: false,
},
{
id: dto.items[1].id,
companyId: dto.items[1].company_id,
code: "iva_10",
name: "IVA 10",
description: null,
rate: 10,
taxFamily: "iva",
jurisdictionCountryCode: "ES",
jurisdictionRegionCode: null,
taxScope: "domestic",
validFrom: "2024-01-01",
validTo: "2025-12-31",
isActive: false,
isSystem: true,
},
], ],
}); });
}); });

View File

@ -1,3 +1,5 @@
import { Percentage } from "@repo/rdx-ddd";
import type { ListTaxDefinitionsResponseDTO } from "../../../../common"; import type { ListTaxDefinitionsResponseDTO } from "../../../../common";
import type { ListTaxDefinitionsResult } from "../api"; import type { ListTaxDefinitionsResult } from "../api";
import type { TaxDefinitionList, TaxDefinitionListRow } from "../entities"; import type { TaxDefinitionList, TaxDefinitionListRow } from "../entities";
@ -6,12 +8,38 @@ type TaxDefinitionListItemDTO = ListTaxDefinitionsResponseDTO["items"][number];
const TaxDefinitionListRowAdapter = { const TaxDefinitionListRowAdapter = {
fromDto(dto: TaxDefinitionListItemDTO): TaxDefinitionListRow { fromDto(dto: TaxDefinitionListItemDTO): TaxDefinitionListRow {
return { id: dto.id, companyId: dto.company_id, code: dto.code, name: dto.name, isSystem: dto.is_system, isActive: dto.is_active }; const rate = Percentage.fromObjectString(dto.rate);
if (rate.isFailure) {
throw rate.error;
}
return {
id: dto.id,
companyId: dto.company_id,
code: dto.code,
name: dto.name,
description: dto.description,
rate: rate.data.toNumber(),
taxFamily: dto.tax_family,
jurisdictionCountryCode: dto.jurisdiction_country_code,
jurisdictionRegionCode: dto.jurisdiction_region_code,
taxScope: dto.tax_scope,
validFrom: dto.valid_from,
validTo: dto.valid_to,
isSystem: dto.is_system,
isActive: dto.is_active,
};
}, },
}; };
export const ListTaxDefinitionsAdapter = { export const ListTaxDefinitionsAdapter = {
fromDto(dto: ListTaxDefinitionsResult): TaxDefinitionList { fromDto(dto: ListTaxDefinitionsResult): TaxDefinitionList {
return { page: dto.page, perPage: dto.per_page, totalPages: dto.total_pages, totalItems: dto.total_items, items: dto.items.map(TaxDefinitionListRowAdapter.fromDto) }; return {
page: dto.page,
perPage: dto.per_page,
totalPages: dto.total_pages,
totalItems: dto.total_items,
items: dto.items.map(TaxDefinitionListRowAdapter.fromDto),
};
}, },
}; };

View File

@ -1,8 +1,18 @@
import type { TaxDefinitionFamily, TaxScope } from "./tax-definition.entity";
export interface TaxDefinitionListRow { export interface TaxDefinitionListRow {
id: string; id: string;
companyId: string; companyId: string;
code: string; code: string;
name: string; name: string;
description: string | null;
rate: number;
taxFamily: TaxDefinitionFamily;
jurisdictionCountryCode: string;
jurisdictionRegionCode: string | null;
taxScope: TaxScope;
validFrom: string | null;
validTo: string | null;
isSystem: boolean; isSystem: boolean;
isActive: boolean; isActive: boolean;
} }

View File

@ -66,6 +66,19 @@ export function forbidQueryFieldGuard(field = "companyId"): RequestHandler {
}; };
} }
/**
* Variante para controllers que ejecutan guards mediante ExpressController.
* Evita invocar el siguiente middleware mientras el controller aún está validando la petición.
*/
export function forbidQueryFieldControllerGuard(field = "companyId"): GuardFn {
return ({ req }) => {
if (Object.hasOwn(req.query ?? {}, field)) {
return guardFail(new ValidationApiError(`Query param "${field}" is not allowed`));
}
return guardOk();
};
}
/** /**
Inyecta el tenant del usuario en el body (create/update). Inyecta el tenant del usuario en el body (create/update).
Ignora cualquier valor que venga del cliente. Ignora cualquier valor que venga del cliente.

View File

@ -10,10 +10,52 @@ import {
CompleteRecurringProformaController, CompleteRecurringProformaController,
DeleteRecurringProformaController, DeleteRecurringProformaController,
DuplicateRecurringProformaController, DuplicateRecurringProformaController,
ListRecurringProformasController,
PauseRecurringProformaController, PauseRecurringProformaController,
ResumeRecurringProformaController, ResumeRecurringProformaController,
} from "./recurring-proformas-read.controllers"; } from "./recurring-proformas-read.controllers";
test("list executes controller guards and delegates a valid query", async () => {
const companyId = UniqueID.generateNewID();
const listCalls: Array<{ companyId: typeof companyId }> = [];
let statusCode: number | undefined;
let body: unknown;
const controller = new ListRecurringProformasController(
async (input: { companyId: typeof companyId }) => {
listCalls.push(input);
return Result.ok({ page: 1, perPage: 20, totalItems: 0, totalPages: 0, items: [] });
}
);
const req = {
user: { companyId },
query: {},
originalUrl: "/recurring-proformas",
method: "GET",
protocol: "http",
get: () => undefined,
};
const res = {
status: (value: number) => {
statusCode = value;
return res;
},
json: (value: unknown) => {
body = value;
return res;
},
sendStatus: () => res,
type: () => res,
setHeader: () => undefined,
};
await controller.execute(req as never, res as never, () => undefined);
assert.equal(listCalls.length, 1);
assert.equal(listCalls[0]?.companyId.equals(companyId), true);
assert.equal(statusCode, 200);
assert.deepEqual(body, { page: 1, per_page: 20, total_items: 0, total_pages: 0, items: [] });
});
test("archive success delegates once and returns HTTP success", async () => { test("archive success delegates once and returns HTTP success", async () => {
const companyId = UniqueID.generateNewID(); const companyId = UniqueID.generateNewID();
const recurringProformaId = UniqueID.generateNewID(); const recurringProformaId = UniqueID.generateNewID();

View File

@ -1,6 +1,6 @@
import { import {
ExpressController, ExpressController,
forbidQueryFieldGuard, forbidQueryFieldControllerGuard,
requireAuthenticatedGuard, requireAuthenticatedGuard,
requireCompanyContextGuard, requireCompanyContextGuard,
} from "@erp/core/api"; } from "@erp/core/api";
@ -58,7 +58,7 @@ abstract class RecurringProformaController extends ExpressController {
this.registerGuards( this.registerGuards(
requireAuthenticatedGuard(), requireAuthenticatedGuard(),
requireCompanyContextGuard(), requireCompanyContextGuard(),
forbidQueryFieldGuard("companyId") forbidQueryFieldControllerGuard("companyId")
); );
} }
protected company() { protected company() {