feat(verifactu): add company Verifactu profile management features

- Implemented API for updating company Verifactu profile.
- Created entities for company Verifactu profile and defaults.
- Developed hooks for querying and updating the company Verifactu profile.
- Added UI components for displaying and editing company Verifactu settings.
- Introduced validation and error handling for profile updates.
- Created utility functions for building form data and handling feedback states.
- Added tests for utility functions and error handling.
This commit is contained in:
David Arranz 2026-08-12 13:31:31 +02:00
parent ba1a424393
commit 45200284ed
144 changed files with 6081 additions and 88 deletions

View File

@ -14,6 +14,8 @@ interface BuildModuleRoutesParams {
modules: IModuleClient[]; modules: IModuleClient[];
params: ModuleClientParams; params: ModuleClientParams;
layout: ModuleRouteLayout; layout: ModuleRouteLayout;
excludePathPrefix?: string;
pathPrefix?: string;
} }
interface NormalizeRouteParams { interface NormalizeRouteParams {
@ -27,6 +29,99 @@ interface NormalizeRouteParams {
const DEFAULT_LAYOUT: ModuleRouteLayout = "app-sidebar"; const DEFAULT_LAYOUT: ModuleRouteLayout = "app-sidebar";
const normalizePathPrefix = (pathPrefix?: string) => {
if (!pathPrefix) {
return null;
}
const normalizedPrefix = pathPrefix.trim().replace(/^\/+|\/+$/g, "");
return normalizedPrefix.length > 0 ? normalizedPrefix : null;
};
const splitRoutePath = (path: string) => {
return path
.split("/")
.map((segment) => segment.trim())
.filter(Boolean);
};
const routeMatchesPrefix = (path: string, pathPrefix: string) => {
const routeSegments = splitRoutePath(path);
const prefixSegments = splitRoutePath(pathPrefix);
if (routeSegments.length < prefixSegments.length) {
return false;
}
return prefixSegments.every((segment, index) => routeSegments[index] === segment);
};
const mapRouteToPathPrefix = ({
route,
pathPrefix,
excludePathPrefix,
}: {
route: RouteObject;
pathPrefix: string | null;
excludePathPrefix: string | null;
}): RouteObject | null => {
if (route.index) {
return route;
}
const routePath = typeof route.path === "string" ? route.path : undefined;
const normalizedChildren = route.children
?.map((child) =>
mapRouteToPathPrefix({
route: child,
pathPrefix,
excludePathPrefix,
})
)
.filter((child): child is RouteObject => child !== null);
if (pathPrefix) {
if (!routePath) {
if (!normalizedChildren?.length) {
return null;
}
return {
...route,
children: normalizedChildren,
} satisfies RouteObject;
}
if (!routeMatchesPrefix(routePath, pathPrefix)) {
return null;
}
const routeSegments = splitRoutePath(routePath);
const prefixSegments = splitRoutePath(pathPrefix);
const remainingPath = routeSegments.slice(prefixSegments.length).join("/");
return {
...route,
path: remainingPath || undefined,
children: normalizedChildren ?? route.children,
} satisfies RouteObject;
}
if (excludePathPrefix && routePath && routeMatchesPrefix(routePath, excludePathPrefix)) {
return null;
}
if (normalizedChildren) {
return {
...route,
children: normalizedChildren,
} satisfies RouteObject;
}
return route;
};
const resolveRouteLayout = ( const resolveRouteLayout = (
route: ModuleRouteObject, route: ModuleRouteObject,
module: IModuleClient, module: IModuleClient,
@ -127,8 +222,12 @@ export const buildModuleRoutes = ({
modules, modules,
params, params,
layout, layout,
excludePathPrefix,
pathPrefix,
}: BuildModuleRoutesParams): RouteObject[] => { }: BuildModuleRoutesParams): RouteObject[] => {
const routes: RouteObject[] = []; const routes: RouteObject[] = [];
const normalizedPathPrefix = normalizePathPrefix(pathPrefix);
const normalizedExcludePathPrefix = normalizePathPrefix(excludePathPrefix);
for (const module of modules) { for (const module of modules) {
if (typeof module.routes !== "function") { if (typeof module.routes !== "function") {
@ -153,8 +252,18 @@ export const buildModuleRoutes = ({
targetLayout: layout, targetLayout: layout,
}); });
if (normalizedRoute) { if (!normalizedRoute) {
routes.push(normalizedRoute); continue;
}
const mappedRoute = mapRouteToPathPrefix({
route: normalizedRoute,
pathPrefix: normalizedPathPrefix,
excludePathPrefix: normalizedExcludePathPrefix,
});
if (mappedRoute) {
routes.push(mappedRoute);
} }
} }
} }

View File

@ -14,30 +14,10 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils"; import { cn } from "@repo/shadcn-ui/lib/utils";
import { import { ChevronRightIcon, SettingsIcon } from "lucide-react";
Building2Icon,
ChevronRightIcon,
CoinsIcon,
PercentIcon,
SettingsIcon,
SlidersHorizontalIcon,
UserCogIcon,
} from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
interface AppSidebarSettingsItem { import { appSidebarSettingsItems } from "./app-sidebar.config";
title: string;
href: string;
icon: typeof UserCogIcon;
}
const settingsItems: AppSidebarSettingsItem[] = [
{ title: "Usuarios y roles", href: "/settings/users", icon: UserCogIcon },
{ title: "Empresas", href: "/settings/companies", icon: Building2Icon },
{ title: "Impuestos", href: "/settings/taxes", icon: PercentIcon },
{ title: "Monedas", href: "/settings/currencies", icon: CoinsIcon },
{ title: "Parámetros", href: "/settings", icon: SlidersHorizontalIcon },
];
interface AppSidebarSettingsMenuProps { interface AppSidebarSettingsMenuProps {
className?: string; className?: string;
@ -80,7 +60,7 @@ export const AppSidebarSettingsMenu = ({ className }: AppSidebarSettingsMenuProp
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
{settingsItems.map((item) => { {appSidebarSettingsItems.map((item) => {
const Icon = item.icon; const Icon = item.icon;
return ( return (

View File

@ -3,20 +3,18 @@ import {
BanknoteIcon, BanknoteIcon,
BarChart3Icon, BarChart3Icon,
BoxesIcon, BoxesIcon,
Building2Icon,
ClipboardListIcon, ClipboardListIcon,
CoinsIcon,
CreditCardIcon, CreditCardIcon,
FileTextIcon, FileTextIcon,
LandmarkIcon, LandmarkIcon,
PackageIcon, PackageIcon,
PercentIcon,
ReceiptIcon, ReceiptIcon,
RefreshCcwIcon, RefreshCcwIcon,
ShoppingCartIcon, ShoppingCartIcon,
SlidersHorizontalIcon, SlidersHorizontalIcon,
SettingsIcon,
ShieldCheckIcon,
TruckIcon, TruckIcon,
UserCogIcon,
UsersIcon, UsersIcon,
WarehouseIcon, WarehouseIcon,
} from "lucide-react"; } from "lucide-react";
@ -116,9 +114,6 @@ export interface AppSidebarSettingsItem {
} }
export const appSidebarSettingsItems: AppSidebarSettingsItem[] = [ export const appSidebarSettingsItems: AppSidebarSettingsItem[] = [
{ title: "Usuarios y roles", href: "/settings/users", icon: UserCogIcon }, { title: "Configuración", href: "/settings", icon: SettingsIcon },
{ title: "Empresas", href: "/settings/companies", icon: Building2Icon }, { title: "Verifactu", href: "/settings/billing/verifactu", icon: ShieldCheckIcon },
{ title: "Impuestos", href: "/settings/taxes", icon: PercentIcon },
{ title: "Monedas", href: "/settings/currencies", icon: CoinsIcon },
{ title: "Parámetros", href: "/settings", icon: SlidersHorizontalIcon },
]; ];

View File

@ -33,6 +33,19 @@
"retrying": "Retrying…", "retrying": "Retrying…",
"goHome": "Go to home", "goHome": "Go to home",
"goBack": "Go back" "goBack": "Go back"
},
"settings": {
"title": "Settings",
"description": "Configure the behavior of the active company.",
"sections": {
"company": "Company",
"billing": "Billing"
},
"empty": {
"title": "There are no settings available yet",
"description": "Module configuration entries will appear here once they are published in the shell.",
"navigation": "There are no sections available yet."
}
} }
} }
} }

View File

@ -33,6 +33,19 @@
"retrying": "Reintentando…", "retrying": "Reintentando…",
"goHome": "Ir al inicio", "goHome": "Ir al inicio",
"goBack": "Volver" "goBack": "Volver"
},
"settings": {
"title": "Configuración",
"description": "Configura el comportamiento de la empresa activa.",
"sections": {
"company": "Empresa",
"billing": "Facturación"
},
"empty": {
"title": "Todavía no hay configuraciones disponibles",
"description": "Las opciones de configuración de los módulos aparecerán aquí cuando se publiquen en el shell.",
"navigation": "No hay secciones disponibles todavía."
}
} }
} }
} }

View File

@ -7,15 +7,19 @@ import esResources from "./es.json";
const APP_MODULE_NAME = "FACTUGES_WEB"; const APP_MODULE_NAME = "FACTUGES_WEB";
const ensureAppTranslations = () => { export const ensureAppTranslations = () => {
registerTranslations(APP_MODULE_NAME, "es", esResources); registerTranslations(APP_MODULE_NAME, "es", esResources);
registerTranslations(APP_MODULE_NAME, "en", enResources); registerTranslations(APP_MODULE_NAME, "en", enResources);
}; };
ensureAppTranslations();
/** /**
* Hook de traducción específico de la app principal. * Hook de traducción específico de la app principal.
*/ */
export const useAppTranslation = () => { export const useAppTranslation = () => {
ensureAppTranslations();
// biome-ignore lint/correctness/useExhaustiveDependencies: <Registro idempotente ligado a la instancia compartida> // biome-ignore lint/correctness/useExhaustiveDependencies: <Registro idempotente ligado a la instancia compartida>
useEffect(() => { useEffect(() => {
ensureAppTranslations(); ensureAppTranslations();

View File

@ -13,6 +13,7 @@ import {
} from "@/layout"; } from "@/layout";
import { UiKitPage } from "@/development/ui-kit"; import { UiKitPage } from "@/development/ui-kit";
import ShadcnShowcasePage from "@/pages/shadcn-ui-page"; import ShadcnShowcasePage from "@/pages/shadcn-ui-page";
import { buildSettingsRoute } from "@/settings";
import TailwindV4ShowcasePage from "@/pages/tailwindcss-page"; import TailwindV4ShowcasePage from "@/pages/tailwindcss-page";
import { RouteErrorPage, StaticRouteErrorPage } from "../pages"; import { RouteErrorPage, StaticRouteErrorPage } from "../pages";
@ -50,6 +51,7 @@ export const getAppRouter = () => {
const appSidebarRoutes = buildModuleRoutes({ const appSidebarRoutes = buildModuleRoutes({
modules, modules,
params, params,
excludePathPrefix: "settings",
layout: "app-sidebar", layout: "app-sidebar",
}); });
@ -119,10 +121,6 @@ export const getAppRouter = () => {
path: "dashboard", path: "dashboard",
element: <StaticRouteErrorPage variant="notFound" />, element: <StaticRouteErrorPage variant="notFound" />,
}, },
{
path: "settings",
element: <StaticRouteErrorPage variant="notFound" />,
},
{ {
path: "catalog", path: "catalog",
element: <StaticRouteErrorPage variant="notFound" />, element: <StaticRouteErrorPage variant="notFound" />,
@ -131,6 +129,10 @@ export const getAppRouter = () => {
path: "quotes", path: "quotes",
element: <StaticRouteErrorPage variant="notFound" />, element: <StaticRouteErrorPage variant="notFound" />,
}, },
buildSettingsRoute({
modules,
params,
}),
...developmentRoutes, ...developmentRoutes,
...appSidebarRoutes, ...appSidebarRoutes,

View File

@ -0,0 +1,191 @@
/// <reference types="node" />
import assert from "node:assert/strict";
import test from "node:test";
import type { IModuleClient } from "@erp/core/client";
import { buildSettingsNavigation } from "./build-settings-navigation";
import { settingsSectionCatalog } from "./settings-section-catalog";
const createModule = (overrides: Partial<IModuleClient>): IModuleClient => {
return {
name: "TestModule",
version: "1.0.0",
...overrides,
};
};
test("ignora módulos sin settings", () => {
const result = buildSettingsNavigation({
modules: [
createModule({
name: "WithoutSettings",
}),
],
sections: settingsSectionCatalog,
});
assert.deepEqual(result, []);
});
test("recoge una entry de settings y la asocia a su sección", () => {
const result = buildSettingsNavigation({
modules: [
createModule({
name: "Customers",
settings: {
entries: [
{
id: "company-details",
sectionId: "company",
labelKey: "settings.company.details",
path: "/settings/company/details",
},
],
},
}),
],
sections: settingsSectionCatalog,
});
assert.deepEqual(result, [
{
id: "company",
labelKey: "web.settings.sections.company",
entries: [
{
id: "company-details",
sectionId: "company",
labelKey: "settings.company.details",
path: "/settings/company/details",
moduleName: "Customers",
},
],
},
]);
});
test("agrega entries de dos módulos en la misma sección", () => {
const result = buildSettingsNavigation({
modules: [
createModule({
name: "CustomerInvoices",
settings: {
entries: [
{
id: "verifactu",
sectionId: "billing",
labelKey: "settings.verifactu.title",
path: "/settings/billing/verifactu",
},
],
},
}),
createModule({
name: "DocumentSeries",
settings: {
entries: [
{
id: "document-series",
sectionId: "billing",
labelKey: "settings.documentSeries.title",
path: "/settings/billing/document-series",
},
],
},
}),
],
sections: settingsSectionCatalog,
});
assert.deepEqual(result, [
{
id: "billing",
labelKey: "web.settings.sections.billing",
entries: [
{
id: "verifactu",
sectionId: "billing",
labelKey: "settings.verifactu.title",
path: "/settings/billing/verifactu",
moduleName: "CustomerInvoices",
},
{
id: "document-series",
sectionId: "billing",
labelKey: "settings.documentSeries.title",
path: "/settings/billing/document-series",
moduleName: "DocumentSeries",
},
],
},
]);
});
test("falla si un módulo declara una sección inexistente", () => {
assert.throws(
() =>
buildSettingsNavigation({
modules: [
createModule({
name: "BrokenModule",
settings: {
entries: [
{
id: "unknown",
sectionId: "unknown",
labelKey: "settings.unknown.title",
path: "/settings/unknown/item",
},
],
},
}),
],
sections: settingsSectionCatalog,
}),
/sección inexistente "unknown"/i
);
});
test("produce un resultado determinista", () => {
const modules = [
createModule({
name: "A",
settings: {
entries: [
{
id: "company-details",
sectionId: "company",
labelKey: "settings.company.details",
path: "/settings/company/details",
},
],
},
}),
createModule({
name: "B",
settings: {
entries: [
{
id: "verifactu",
sectionId: "billing",
labelKey: "settings.verifactu.title",
path: "/settings/billing/verifactu",
},
],
},
}),
];
const firstResult = buildSettingsNavigation({
modules,
sections: settingsSectionCatalog,
});
const secondResult = buildSettingsNavigation({
modules,
sections: settingsSectionCatalog,
});
assert.deepEqual(secondResult, firstResult);
});

View File

@ -0,0 +1,58 @@
import type { IModuleClient, ModuleSettingsEntryContribution } from "@erp/core/client";
import type { SettingsSectionCatalogItem } from "./settings-section-catalog";
export interface SettingsNavigationEntry extends ModuleSettingsEntryContribution {
moduleName: string;
}
export interface SettingsNavigationSection extends SettingsSectionCatalogItem {
entries: SettingsNavigationEntry[];
}
const buildSectionMap = (sections: SettingsSectionCatalogItem[]) => {
return new Map(sections.map((section) => [section.id, section]));
};
export const buildSettingsNavigation = ({
modules,
sections,
}: {
modules: IModuleClient[];
sections: SettingsSectionCatalogItem[];
}): SettingsNavigationSection[] => {
const sectionMap = buildSectionMap(sections);
const entriesBySection = new Map<string, SettingsNavigationEntry[]>();
for (const module of modules) {
if (!module.settings) {
continue;
}
for (const entry of module.settings.entries) {
const section = sectionMap.get(entry.sectionId);
if (!section) {
throw new Error(
`[Settings] El módulo "${module.name}" declara la sección inexistente "${entry.sectionId}".`
);
}
const sectionEntries = entriesBySection.get(entry.sectionId) ?? [];
sectionEntries.push({
...entry,
moduleName: module.name,
});
entriesBySection.set(entry.sectionId, sectionEntries);
}
}
return sections
.map((section) => ({
...section,
entries: entriesBySection.get(section.id) ?? [],
}))
.filter((section) => section.entries.length > 0);
};

View File

@ -0,0 +1,127 @@
/// <reference types="node" />
import assert from "node:assert/strict";
import test from "node:test";
import type { IModuleClient, ModuleRouteObject } from "@erp/core/client";
import { i18n, registerTranslations } from "@repo/i18next";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nextProvider } from "react-i18next";
import { RouterProvider, createMemoryRouter } from "react-router-dom";
import { ensureAppTranslations } from "../locales/i18n";
import { buildSettingsRoute } from "./build-settings-routes";
const SETTINGS_ROUTE_TEST_MODULE = "SettingsRouteTestModule";
const createModule = ({
name,
routes,
settings,
}: {
name: string;
routes?: ModuleRouteObject[];
settings?: IModuleClient["settings"];
}): IModuleClient => {
return {
name,
version: "1.0.0",
protected: false,
layout: "app-sidebar",
routes: () => routes ?? [],
settings,
};
};
const renderSettingsRouter = async ({
initialEntry,
modules,
}: {
initialEntry: string;
modules: IModuleClient[];
}) => {
ensureAppTranslations();
registerTranslations(SETTINGS_ROUTE_TEST_MODULE, "es", {
settings: {
companyDetails: {
title: "Datos de empresa",
},
},
});
await i18n.changeLanguage("es");
const router = createMemoryRouter(
[
{
path: "/",
children: [
buildSettingsRoute({
modules,
params: {},
protectRoute: false,
}),
],
},
],
{
initialEntries: [initialEntry],
}
);
return renderToStaticMarkup(
<I18nextProvider i18n={i18n}>
<RouterProvider router={router} />
</I18nextProvider>
);
};
test("renderiza /settings con estado vacío cuando no hay entries", async () => {
const html = await renderSettingsRouter({
initialEntry: "/settings",
modules: [],
});
assert.match(html, /Configuración/);
assert.match(html, /Todavía no hay configuraciones disponibles/);
});
test("resuelve un deep link modular bajo /settings", async () => {
const html = await renderSettingsRouter({
initialEntry: "/settings/company/details",
modules: [
createModule({
name: SETTINGS_ROUTE_TEST_MODULE,
settings: {
entries: [
{
id: "company-details",
sectionId: "company",
labelKey: "settings.companyDetails.title",
path: "/settings/company/details",
},
],
},
routes: [
{
path: "settings/company/details",
element: <div>Contenido real de configuracion</div>,
},
],
}),
],
});
assert.match(html, /Configuración/);
assert.match(html, /Datos de empresa/);
assert.match(html, /Contenido real de configuracion/);
});
test("muestra 404 para una ruta inválida bajo /settings", async () => {
const html = await renderSettingsRouter({
initialEntry: "/settings/unknown/path",
modules: [],
});
assert.match(html, /Página no encontrada/);
});

View File

@ -0,0 +1,78 @@
import type { IModuleClient, ModuleClientParams } from "@erp/core/client";
import type { RouteObject } from "react-router-dom";
import { Navigate } from "react-router-dom";
import { buildModuleRoutes } from "@/components/module-routes";
import { StaticRouteErrorPage } from "@/pages";
import { RequireAuthRouteGuard } from "@/routes/require-auth-guard";
import { RequireCompanyRouteGuard } from "@/routes/require-company-guard";
import { buildSettingsNavigation } from "./build-settings-navigation";
import { SettingsIndexPage } from "./settings-index-page";
import { SettingsLayout } from "./settings-layout";
import { settingsSectionCatalog } from "./settings-section-catalog";
const SettingsIndexRoute = ({
hasEntries,
redirectPath,
}: {
hasEntries: boolean;
redirectPath?: string;
}) => {
if (hasEntries && redirectPath) {
return <Navigate replace to={redirectPath} />;
}
return <SettingsIndexPage />;
};
export const buildSettingsRoute = ({
modules,
params,
protectRoute = true,
}: {
modules: IModuleClient[];
params: ModuleClientParams;
protectRoute?: boolean;
}): RouteObject => {
const settingsSections = buildSettingsNavigation({
modules,
sections: settingsSectionCatalog,
});
const firstEntryPath = settingsSections[0]?.entries[0]?.path;
const settingsChildRoutes = buildModuleRoutes({
modules,
params,
layout: "app-sidebar",
pathPrefix: "settings",
});
const settingsLayout = <SettingsLayout sections={settingsSections} />;
const settingsElement = protectRoute ? (
<RequireAuthRouteGuard>
<RequireCompanyRouteGuard>{settingsLayout}</RequireCompanyRouteGuard>
</RequireAuthRouteGuard>
) : (
settingsLayout
);
return {
path: "settings",
element: settingsElement,
children: [
{
index: true,
element: (
<SettingsIndexRoute
hasEntries={Boolean(firstEntryPath)}
redirectPath={firstEntryPath}
/>
),
},
...settingsChildRoutes,
{
path: "*",
element: <StaticRouteErrorPage variant="notFound" />,
},
],
};
};

View File

@ -0,0 +1,6 @@
export * from "./build-settings-navigation";
export * from "./build-settings-routes";
export * from "./settings-index-page";
export * from "./settings-layout";
export * from "./settings-navigation";
export * from "./settings-section-catalog";

View File

@ -0,0 +1,16 @@
import { useAppTranslation } from "@/locales/i18n";
export const SettingsIndexPage = () => {
const { t } = useAppTranslation();
return (
<div className="rounded-xl border border-border bg-card p-6 shadow-sm">
<div className="max-w-2xl space-y-2">
<h2 className="text-lg font-semibold text-foreground">{t("web.settings.empty.title")}</h2>
<p className="text-sm leading-6 text-muted-foreground">
{t("web.settings.empty.description")}
</p>
</div>
</div>
);
};

View File

@ -0,0 +1,37 @@
import { RdxPageContainer, RdxPageHeader, RdxPageStack } from "@repo/rdx-ui/components";
import { Outlet } from "react-router-dom";
import type { SettingsNavigationSection } from "@/settings/build-settings-navigation";
import { useAppTranslation } from "../locales/i18n";
import { SettingsNavigation } from "./settings-navigation";
interface SettingsLayoutProps {
sections: SettingsNavigationSection[];
}
export const SettingsLayout = ({ sections }: SettingsLayoutProps) => {
const { t } = useAppTranslation();
return (
<RdxPageContainer width="full">
<RdxPageStack spacing="default">
<RdxPageHeader
description={t("web.settings.description")}
title={t("web.settings.title")}
/>
<div className="grid gap-6 lg:grid-cols-[18rem_minmax(0,1fr)]">
<aside className="rounded-xl border border-border bg-card p-4 shadow-sm lg:sticky lg:top-6 lg:self-start">
<SettingsNavigation sections={sections} />
</aside>
<section className="min-w-0">
<Outlet />
</section>
</div>
</RdxPageStack>
</RdxPageContainer>
);
};

View File

@ -0,0 +1,121 @@
/// <reference types="node" />
import assert from "node:assert/strict";
import test from "node:test";
import { i18n, registerTranslations } from "@repo/i18next";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nextProvider } from "react-i18next";
import { MemoryRouter } from "react-router-dom";
import { ensureAppTranslations } from "../locales/i18n";
import type { SettingsNavigationSection } from "./build-settings-navigation";
import { SettingsNavigation } from "./settings-navigation";
const SETTINGS_TEST_MODULE = "SettingsTestModule";
const renderNavigation = async ({
currentPath,
sections,
}: {
currentPath: string;
sections: SettingsNavigationSection[];
}) => {
ensureAppTranslations();
registerTranslations(SETTINGS_TEST_MODULE, "es", {
settings: {
companyDetails: {
title: "Datos de empresa",
},
verifactu: {
title: "VeriFactu",
},
},
});
await i18n.changeLanguage("es");
return renderToStaticMarkup(
<I18nextProvider i18n={i18n}>
<MemoryRouter initialEntries={[currentPath]}>
<SettingsNavigation sections={sections} />
</MemoryRouter>
</I18nextProvider>
);
};
test("renderiza secciones y entries con links correctos", async () => {
const html = await renderNavigation({
currentPath: "/settings/company/details",
sections: [
{
id: "company",
labelKey: "web.settings.sections.company",
entries: [
{
id: "company-details",
sectionId: "company",
labelKey: "settings.companyDetails.title",
path: "/settings/company/details",
moduleName: SETTINGS_TEST_MODULE,
},
],
},
{
id: "billing",
labelKey: "web.settings.sections.billing",
entries: [
{
id: "verifactu",
sectionId: "billing",
labelKey: "settings.verifactu.title",
path: "/settings/billing/verifactu",
moduleName: SETTINGS_TEST_MODULE,
},
],
},
],
});
assert.match(html, /<nav[^>]*aria-label="Configuración"/);
assert.match(html, />Empresa</);
assert.match(html, />Facturación</);
assert.match(html, /href="\/settings\/company\/details"/);
assert.match(html, /href="\/settings\/billing\/verifactu"/);
assert.match(html, />Datos de empresa</);
assert.match(html, />VeriFactu</);
});
test("marca la entry activa con aria-current=page", async () => {
const html = await renderNavigation({
currentPath: "/settings/billing/verifactu",
sections: [
{
id: "billing",
labelKey: "web.settings.sections.billing",
entries: [
{
id: "verifactu",
sectionId: "billing",
labelKey: "settings.verifactu.title",
path: "/settings/billing/verifactu",
moduleName: SETTINGS_TEST_MODULE,
},
],
},
],
});
assert.match(html, /aria-current="page"/);
assert.match(html, /VeriFactu/);
});
test("muestra un estado vacío cuando no hay secciones disponibles", async () => {
const html = await renderNavigation({
currentPath: "/settings",
sections: [],
});
assert.match(html, /No hay secciones disponibles todavía/);
assert.doesNotMatch(html, /<ul/);
});

View File

@ -0,0 +1,62 @@
import { i18n } from "@repo/i18next";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { NavLink } from "react-router-dom";
import type { SettingsNavigationSection } from "@/settings/build-settings-navigation";
import { useAppTranslation } from "../locales/i18n";
interface SettingsNavigationProps {
sections: SettingsNavigationSection[];
}
const resolveEntryLabel = (labelKey: string, moduleName: string) => {
return i18n.t(labelKey, {
defaultValue: labelKey,
ns: moduleName,
});
};
export const SettingsNavigation = ({ sections }: SettingsNavigationProps) => {
const { t } = useAppTranslation();
if (sections.length === 0) {
return (
<p className="text-sm leading-6 text-muted-foreground">
{t("web.settings.empty.navigation")}
</p>
);
}
return (
<nav aria-label={t("web.settings.title")} className="space-y-6">
{sections.map((section) => (
<div className="space-y-2" key={section.id}>
<h2 className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{t(section.labelKey)}
</h2>
<ul className="space-y-1">
{section.entries.map((entry) => (
<li key={`${entry.moduleName}:${entry.id}`}>
<NavLink
className={({ isActive }) =>
cn(
"block rounded-lg px-3 py-2 text-sm font-medium text-foreground/80 transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"hover:bg-muted hover:text-foreground",
isActive && "bg-muted text-foreground"
)
}
to={entry.path}
>
{resolveEntryLabel(entry.labelKey, entry.moduleName)}
</NavLink>
</li>
))}
</ul>
</div>
))}
</nav>
);
};

View File

@ -0,0 +1,15 @@
export interface SettingsSectionCatalogItem {
id: string;
labelKey: string;
}
export const settingsSectionCatalog: SettingsSectionCatalogItem[] = [
{
id: "company",
labelKey: "web.settings.sections.company",
},
{
id: "billing",
labelKey: "web.settings.sections.billing",
},
];

View File

@ -24,11 +24,15 @@ Este directorio reúne la documentación técnica, operativa y de apoyo del ERP.
#### Frontend #### Frontend
- [Flujo de auth y selección de empresa](./frontend/auth-and-company-selection.md) - [Flujo de auth y selección de empresa](./frontend/auth-and-company-selection.md)
- [Sistema modular de Settings](./settings/frontend-erp-settings-system-design.md)
- [Integración de módulos con Settings](./settings/frontend-erp-settings-module-integration-design.md)
- [README de `apps/web`](../apps/web/README.md) - [README de `apps/web`](../apps/web/README.md)
#### Customer invoices y proformas #### Customer invoices y proformas
- [Índice de `customer-invoices`](./customer-invoices/README.md) - [Índice de `customer-invoices`](./customer-invoices/README.md)
- [Diseño backend del perfil VeriFactu empresarial](./customer-invoices/settings/verifactu-company-profile-backend-design.md)
- [Diseño frontend de Settings para VeriFactu](./customer-invoices/settings/frontend-erp-settings-verifactu-design.md)
- [Contrato de series de proformas](./customer-invoices/proforma-series-contract.md) - [Contrato de series de proformas](./customer-invoices/proforma-series-contract.md)
- [Contrato de create de proformas](./customer-invoices/proforma-create-contract.md) - [Contrato de create de proformas](./customer-invoices/proforma-create-contract.md)
- [Configuración fiscal persistida en proformas](./customer-invoices/proforma-tax-config.md) - [Configuración fiscal persistida en proformas](./customer-invoices/proforma-tax-config.md)

View File

@ -6,6 +6,7 @@
### Contratos funcionales y de transporte ### Contratos funcionales y de transporte
- [Diseño backend del perfil VeriFactu empresarial](./settings/verifactu-company-profile-backend-design.md)
- [Contrato de series de proformas](./proforma-series-contract.md) - [Contrato de series de proformas](./proforma-series-contract.md)
- [Contrato de create de proformas](./proforma-create-contract.md) - [Contrato de create de proformas](./proforma-create-contract.md)
- [Configuración fiscal persistida en proformas](./proforma-tax-config.md) - [Configuración fiscal persistida en proformas](./proforma-tax-config.md)

View File

@ -0,0 +1,209 @@
# Frontend ERP – Settings VeriFactu
## Estado
Implementado en V1 como primera pantalla funcional del sistema modular de Settings:
```txt
/settings/billing/verifactu
```
## Arquitectura real
```txt
CustomerInvoicesModuleManifest.settings.entries
│
├── SettingsNavigation
└── buildSettingsRoute(pathPrefix="settings")
│
▼
customer-invoices route: settings/billing/verifactu
│
▼
Page Controller
├── useCompanyVerifactuProfileQuery
└── useCompanyVerifactuProfileController
│
▼
UI sections
├── status
├── configuration
└── defaults
```
## Integración con Settings
- El módulo `customer-invoices` publica una única entry de Settings:
```ts
{
id: "verifactu",
sectionId: "billing",
labelKey: "settings.verifactu.title",
path: "/settings/billing/verifactu",
}
```
- La pantalla se registra como ruta modular del propio módulo con `path: "settings/billing/verifactu"`.
- No se modificó el shell de Settings para esta feature.
## Capa de datos
Estructura implementada:
```txt
API
→ DTO common
→ adapter
→ Entity
→ React Query hook
→ controller
→ page controller
→ UI
```
Archivos clave:
```txt
modules/customer-invoices/src/web/settings/verifactu/shared/
modules/customer-invoices/src/web/settings/verifactu/controllers/
modules/customer-invoices/src/web/settings/verifactu/ui/
```
## DTOs reutilizados
Se reutilizan los DTOs publicados por `@erp/customer-invoices/common`:
- `CompanyVerifactuProfileResponseDTO`
- `UpdateCompanyVerifactuProfileRequestDTO`
- `UpdateCompanyVerifactuProfileRequestSchema`
No se usan DTOs `snake_case` directamente en UI.
## Entity frontend
```ts
interface CompanyVerifactuProfile {
profileStatus:
| "not_configured"
| "exempt"
| "obligated_without_verifactu"
| "obligated_with_verifactu";
configured: boolean;
usesVerifactu: boolean;
exemptionReasonCode: string | null;
exemptionReasonDetail: string | null;
verifactuEffectiveAt: string | null;
autoRenewMode: "disabled" | "enabled" | null;
defaults: {
taxRegimeCode: string | null;
operationQualificationCode: string | null;
exemptionReasonCode: string | null;
};
updatedAt: string | null;
}
```
## Form model
Se mantiene la separación:
```txt
Form ≠ Entity ≠ DTO ≠ Params
```
El formulario:
- usa `camelCase`;
- convierte `updatedAt` fuera del form;
- guarda `verifactuEffectiveAt` como fecha civil `yyyy-MM-dd`;
- normaliza strings vacíos a `null` en el builder de salida.
## Reglas implementadas en frontend
### Estado `not_configured`
- no muestra una opción explícita de radio;
- se usa como estado técnico inicial;
- al guardar se obliga al usuario a elegir un estado configurado real;
- limpia defaults y campos de exención/VeriFactu.
### Estado `exempt`
- muestra solo detalle libre de exención;
- no muestra activación VeriFactu;
- no inventa catálogo de motivos de exención.
### Estado `obligated_without_verifactu`
- limpia `verifactuEffectiveAt`;
- limpia `autoRenewMode`.
### Estado `obligated_with_verifactu`
- muestra `verifactuEffectiveAt`;
- muestra `autoRenewMode`;
- requiere fecha efectiva en Zod.
## Catálogos
### Reutilizado
- `taxRegimeCode` usa el catálogo real existente a través de:
```txt
@erp/catalogs/client/tax-regimes
```
### No implementado todavía
- `operationQualificationCode`
- `defaults.exemptionReasonCode`
- `exemptionReasonCode` como catálogo legal
En V1 no se inventan selects vacíos ni códigos ficticios. Se documenta la limitación en la propia UI.
## UX implementada
La pantalla se resuelve como editor único, no wizard:
```txt
1. Situación
2. Configuración VeriFactu
3. Valores por defecto
4. Footer sticky con guardar/restaurar
```
Se priorizó una primera versión estable y verificable.
## Errores y loading
- Carga inicial con skeleton.
- Error de carga con `PageErrorState`.
- Error de guardado con `ErrorAlert` y toasts.
- Validación de form con foco al primer error.
## Cambio de empresa
No se añadió lógica específica.
La feature depende del comportamiento global ya existente:
- tenant context del ERP;
- reset global de React Query al cambiar de empresa;
- `form.reset(...)` al recibir el nuevo perfil.
## Tests añadidos
- adapter `snake_case -> camelCase`
- builder de `Form -> Update params`
- page error view model
- page feedback state
- manifest/routing básico del módulo
## Limitaciones reales de esta V1
- no hay wizard inicial;
- no hay catálogo backend para `operationQualificationCode` ni para motivos de exención;
- no se implementa ninguna UI de certificados;
- no se integra con `IssuedInvoice` ni con estados operativos AEAT.

View File

@ -0,0 +1,628 @@
# Backend ERP – Perfil VeriFactu empresarial
## 1. Estado del documento
Estado: **IMPLEMENTADO PARCIALMENTE**
Este documento define el diseño backend del perfil VeriFactu de empresa dentro del módulo `customer-invoices`.
Decisiones consolidadas:
- El perfil VeriFactu pertenece al bounded context `customer-invoices`.
- Es una configuración separada de `Company`.
- Tiene scope de empresa mediante `companyId`.
- El estado de configuración es explícito.
- Se permite `obligated_without_verifactu` como estado real.
- Los catálogos de clasificación y exención quedan modelados por código pero no se cierran todavía como enums.
- `IssuedInvoice` deberá persistir dos snapshots distintos en una fase posterior:
- clasificación fiscal efectiva;
- política VeriFactu efectiva.
- `VerifactuRecord` mantiene responsabilidad operativa y no absorbe el perfil empresarial.
- El perfil no expone secretos ni passwords de certificados.
## 2. Propósito
Proporcionar una fuente de verdad empresarial para:
- aplicabilidad de la normativa;
- exención y su motivo;
- uso o no de modalidad VeriFactu;
- fecha efectiva de activación;
- renovación automática si aplica;
- defaults de clasificación fiscal;
- snapshot histórico al emitir una factura.
El diseño debe integrarse con el flujo actual:
```txt
Proforma
→ IssueProformaUseCase
→ ProformaIssueReadModelAssembler
→ proforma-to-issued-invoice-props-converter
→ IssuedInvoice
→ VerifactuRecord
```
sin trasladar esta resolución al frontend.
## 3. Ownership
El perfil debe vivir como agregado/configuración separado dentro de:
```txt
modules/customer-invoices
```
No debe vivir dentro de:
```txt
identity
companies
Company aggregate
```
Motivo:
- gobierna facturación;
- afecta directamente a `IssuedInvoice`;
- sus defaults deben materializarse al emitir;
- identidad solo aporta `companyId`, company context y autorización.
## 4. Modelo de estados
```ts
export const CompanyVerifactuProfileStatus = {
NotConfigured: "not_configured",
Exempt: "exempt",
ObligatedWithoutVerifactu: "obligated_without_verifactu",
ObligatedWithVerifactu: "obligated_with_verifactu",
} as const;
export type CompanyVerifactuProfileStatus =
(typeof CompanyVerifactuProfileStatus)[keyof typeof CompanyVerifactuProfileStatus];
```
Semántica:
```txt
not_configured
→ la empresa aún no ha definido su perfil.
exempt
→ la empresa queda configurada como exenta/no aplicable.
obligated_without_verifactu
→ la empresa está obligada, pero no opera bajo modalidad VeriFactu.
obligated_with_verifactu
→ la empresa está obligada y utiliza VeriFactu.
```
Se acepta `obligated_without_verifactu` como estado real del modelo.
## 5. Invariantes del estado
### `not_configured`
```txt
exemptionReasonCode = null
exemptionReasonDetail = null
verifactuEffectiveAt = null
autoRenewMode = null
```
No debe producir defaults efectivos de clasificación VeriFactu.
### `exempt`
Puede requerir:
```txt
exemptionReasonCode
exemptionReasonDetail
```
No permite activación VeriFactu.
### `obligated_without_verifactu`
```txt
verifactuEffectiveAt = null
autoRenewMode = null
```
Puede tener defaults fiscales si son aplicables.
### `obligated_with_verifactu`
Requiere:
```txt
verifactuEffectiveAt != null
```
`autoRenewMode` solo tiene sentido aquí.
## 6. Obligación y exención
V1:
```ts
exemptionReasonCode: string | null;
exemptionReasonDetail: string | null;
```
No se cierra todavía un enum legal. Los códigos deberán validarse contra un catálogo backend cuando exista.
## 7. Activación VeriFactu
```ts
export const AutoRenewMode = {
Disabled: "disabled",
Enabled: "enabled",
} as const;
export type AutoRenewMode =
(typeof AutoRenewMode)[keyof typeof AutoRenewMode];
```
Campos:
```ts
verifactuEffectiveAt: Date | null;
autoRenewMode: AutoRenewMode | null;
```
No se modelan periodos históricos en V1.
## 8. Defaults empresariales
Solo defaults de clasificación fiscal:
```ts
export interface CompanyVerifactuDefaults {
taxRegimeCode: string | null;
operationQualificationCode: string | null;
exemptionReasonCode: string | null;
}
```
No pertenecen:
```txt
taxPercentage
recPercentage
retentionPercentage
taxMode
tax breakdown
```
## 9. Precedencia de defaults
```txt
document explicit
→ customer
→ company VeriFactu defaults
→ backend fallback
```
Backend resolverá la precedencia cuando la integración con emisión entre en alcance.
## 10. Clasificación fiscal vs cálculo fiscal
### Cálculo fiscal
```txt
taxPercentage
recPercentage
retentionPercentage
taxable base
totals
tax breakdown
```
### Clasificación VeriFactu
```txt
regimeCode
operationQualificationCode
exemptionReasonCode
```
No reutilizar `ProformaFiscalData` como sustituto de clasificación VeriFactu.
## 11. Snapshot de clasificación fiscal
```ts
export interface IssuedInvoiceFiscalClassificationSnapshot {
regimeCode: string | null;
regimeLabel: string | null;
operationQualificationCode: string | null;
operationQualificationLabel: string | null;
exemptionReasonCode: string | null;
exemptionReasonLabel: string | null;
}
```
Queda diseñado, pero no implementado todavía en esta fase.
## 12. Snapshot de política VeriFactu
```ts
export interface IssuedInvoiceVerifactuPolicySnapshot {
profileStatus: CompanyVerifactuProfileStatus;
usesVerifactu: boolean;
effectiveAt: string | null;
}
```
Queda diseñado, pero no implementado todavía en esta fase.
## 13. Resolución durante la emisión
```txt
CompanyVerifactuProfile
│
▼
ProformaIssueReadModelAssembler
│
├── carga proforma
├── carga cliente
├── carga perfil VeriFactu
└── prepara contexto
│
▼
proforma-to-issued-invoice-props-converter
│
├── resuelve precedencia
├── construye FiscalClassificationSnapshot
└── construye VerifactuPolicySnapshot
│
▼
IssuedInvoice.create(...)
```
## 14. Responsabilidad de `VerifactuRecord`
`IssuedInvoice`:
- snapshot documental e histórico;
- clasificación fiscal aplicada;
- política VeriFactu aplicada.
`VerifactuRecord`:
- estado operativo;
- URL;
- QR;
- UUID;
- tipo de operación.
No debe contener defaults empresariales ni obligación/exención.
## 15. Certificados
El perfil no contiene:
```txt
password
private key
secret name
certificate binary
```
Solo podría admitir una referencia lógica no sensible si realmente fuera necesaria.
## 16. Boundary AEAT
Fuera de alcance:
```txt
enqueue
worker/jobs
retries
queries
hash chaining
cancelación
rectificación
payload AEAT
outbox
```
## 17. API
```txt
GET /api/customer-invoices/settings/verifactu
PUT /api/customer-invoices/settings/verifactu
```
Ambos usan `requireIdentityTenant(params)` y los controladores resuelven `companyId` mediante `this.getTenantId()`.
## 18. GET
Si no existe fila:
```txt
profile_status = "not_configured"
configured = false
```
Response conceptual:
```ts
export interface CompanyVerifactuProfileResponseDTO {
profile_status: CompanyVerifactuProfileStatus;
configured: boolean;
uses_verifactu: boolean;
exemption_reason_code: string | null;
exemption_reason_detail: string | null;
verifactu_effective_at: string | null;
auto_renew_mode: AutoRenewMode | null;
defaults: {
tax_regime_code: string | null;
operation_qualification_code: string | null;
exemption_reason_code: string | null;
};
updated_at: string | null;
}
```
## 19. PUT
```ts
export interface UpdateCompanyVerifactuProfileRequestDTO {
profile_status: CompanyVerifactuProfileStatus;
exemption_reason_code?: string | null;
exemption_reason_detail?: string | null;
verifactu_effective_at?: string | null;
auto_renew_mode?: AutoRenewMode | null;
defaults: {
tax_regime_code?: string | null;
operation_qualification_code?: string | null;
exemption_reason_code?: string | null;
};
}
```
Debe ser idempotente para el mismo payload.
## 20. Errores
```txt
400 → combinación estructural inválida
403 → falta de autorización
409 → conflicto de concurrencia si se activa
422 → validación de negocio si encaja con la convención del módulo
```
No usar 404 por ausencia inicial de perfil.
## 21. Persistencia
Tabla conceptual:
```txt
company_verifactu_profiles
```
Campos:
```txt
id
company_id
profile_status
exemption_reason_code
exemption_reason_detail
verifactu_effective_at
auto_renew_mode
default_tax_regime_code
default_operation_qualification_code
default_exemption_reason_code
created_at
updated_at
```
Restricciones:
```txt
company_id NOT NULL
profile_status NOT NULL
UNIQUE(company_id)
```
Una fila activa por empresa.
DDL operativo vigente:
```txt
docs/customer-invoices/sql/add-company-verifactu-profiles.sql
docs/customer-invoices/sql/add-issued-invoice-verifactu-snapshots.sql
```
## 22. Concurrencia
Se difiere en V1.
La implementación actual no introduce `expected_updated_at` ni control optimista específico, porque el backend real no aporta hoy un patrón reutilizable de concurrency/versionado para este tipo de settings.
## 23. Rollout
```txt
sin fila
→ not_configured
```
Orden recomendado:
```txt
1. ejecutar DDL de `company_verifactu_profiles`
2. ejecutar DDL de snapshots VeriFactu en `issued_invoices`
3. persistencia y GET seguro
4. PUT/configuración
5. UI Settings
6. uso de defaults en emisión
7. enforcement posterior
```
La ausencia de perfil no debe romper automáticamente la emisión actual durante transición.
## 24. Seguridad
Nunca aceptar `companyId` del frontend.
Implementado:
- autenticación;
- company context;
- aislamiento por `companyId`.
Permisos finos propuestos para una fase posterior:
```txt
customer-invoices:verifactu-profile:read
customer-invoices:verifactu-profile:update
```
Aún no se han activado guards de permisos específicos, porque el módulo `customer-invoices` no tenía previamente una infraestructura de permiso fino conectada en estas rutas.
## 25. Auditoría
Deben ser auditables cambios en:
```txt
profileStatus
exemptionReasonCode
verifactuEffectiveAt
defaults
```
No se ha implementado auditoría adicional en esta fase.
## 26. Modelo conceptual del agregado
```ts
export interface CompanyVerifactuProfileProps {
companyId: UniqueID;
profileStatus: CompanyVerifactuProfileStatus;
exemptionReasonCode: string | null;
exemptionReasonDetail: string | null;
verifactuEffectiveAt: Date | null;
autoRenewMode: AutoRenewMode | null;
defaults: CompanyVerifactuDefaults;
createdAt: Date;
updatedAt: Date;
}
export class CompanyVerifactuProfile extends AggregateRoot<CompanyVerifactuProfileProps> {
public static create(
props: CompanyVerifactuProfileProps,
): Result<CompanyVerifactuProfile, Error>;
public reconfigure(
props: CompanyVerifactuProfileReconfiguration,
): Result<void, Error>;
}
```
Usar `import type` explícito en cabecera.
## 27. Catálogos pendientes
No se consolidan todavía enums para:
```txt
operationQualificationCode
exemptionReasonCode
```
Se requiere una fase específica para definir:
```txt
fuente normativa
códigos
labels
compatibilidades
versionado
```
## 28. Integración en `IssuedInvoice`
Implementado en Fase B:
```txt
CompanyVerifactuProfile
→ ProformaIssueReadModelAssembler
→ resolución backend de clasificación/política
→ ProformaToIssuedInvoiceConverter
→ IssuedInvoice
→ persistencia física en issued_invoices
```
`IssuedInvoice` mantiene `taxRegime` como snapshot legacy existente y añade además:
```txt
fiscalClassification
verifactuPolicy
```
Decisiones reales:
- `taxRegimeCode` se resuelve con precedencia `document -> customer -> company -> sin valor`.
- `operationQualificationCode` se resuelve hoy desde defaults empresariales o `null`.
- `exemptionReasonCode` se resuelve hoy desde defaults empresariales o `null`.
- `verifactuPolicy.profileStatus` se persiste como señal de snapshot V2.
- facturas antiguas sin esa señal cargan con `fiscalClassification = null` y `verifactuPolicy = null`.
- no se inventan labels de `operationQualification` ni `exemptionReason`; se exponen como `null` hasta disponer de catálogo backend real.
## 29. Tests implementados
Aggregate:
- invariantes por cada estado;
- combinaciones inválidas;
- reconfigure.
Repository:
- una fila por company;
- aislamiento entre companies.
Use cases / controllers:
- GET sin fila;
- PUT válido/inválido;
- autorización;
Fase B:
- precedencia `document -> customer -> company` para `taxRegimeCode`;
- fallback legacy sin perfil;
- validación de `IssuedInvoiceVerifactuPolicy`.
## 30. Criterios de aceptación
1. Existe una configuración VeriFactu por empresa.
2. Pertenece a `customer-invoices`.
3. Los cuatro estados están explícitamente modelados.
4. `obligated_without_verifactu` está soportado.
5. Los defaults se resuelven en backend.
6. Cálculo fiscal y clasificación VeriFactu están separados.
7. `GET` devuelve `not_configured` si no existe fila.
8. `PUT` crea o reconfigura el perfil de forma idempotente para el mismo payload.
9. `VerifactuRecord` sigue siendo operativo.
10. No se exponen secretos.
11. Empresas sin perfil se leen como `not_configured`.
12. `IssuedInvoice` persiste snapshots de clasificación/política sin reescribir facturas legacy.
## 31. Regla mental final
```txt
Company
→ identidad/contexto
CompanyVerifactuProfile
→ política/defaults empresariales
IssuedInvoice
→ snapshot histórico efectivo
VerifactuRecord
→ estado operativo
AEAT integration
→ lifecycle futuro
```

View File

@ -10,6 +10,8 @@ en SQL historico de migracion o validacion.
## SQL vigentes ## SQL vigentes
- `add-company-verifactu-profiles.sql`
- `add-issued-invoice-verifactu-snapshots.sql`
- `add-proforma-document-series-columns.sql` - `add-proforma-document-series-columns.sql`
- `add-proforma-tax-config-columns.sql` - `add-proforma-tax-config-columns.sql`
- `add-issued-invoice-reference.sql` - `add-issued-invoice-reference.sql`
@ -29,10 +31,12 @@ Estos archivos no deben tratarse como el camino normal de entornos nuevos salvo
1. Verificar que la BD objetivo es inequívocamente de desarrollo. 1. Verificar que la BD objetivo es inequívocamente de desarrollo.
2. Hacer backup o confirmar que los datos son descartables. 2. Hacer backup o confirmar que los datos son descartables.
3. Ejecutar el DDL vigente que falte para `proformas`. 3. Ejecutar `add-company-verifactu-profiles.sql` si el entorno aún no tiene el perfil VeriFactu empresarial.
4. Ejecutar `normalize-document-series-snapshots.sql` para completar snapshots y validaciones de seriado. 4. Ejecutar `add-issued-invoice-verifactu-snapshots.sql` si el entorno aún no tiene los snapshots fiscales/política en `issued_invoices`.
5. Ejecutar las validaciones SQL vigentes. 5. Ejecutar el DDL vigente que falte para `proformas`.
6. Levantar backend V2 y validar flujos funcionales. 6. Ejecutar `normalize-document-series-snapshots.sql` para completar snapshots y validaciones de seriado.
7. Ejecutar las validaciones SQL vigentes.
8. Levantar backend V2 y validar flujos funcionales.
## Propiedades de los scripts históricos de split ## Propiedades de los scripts históricos de split

View File

@ -0,0 +1,39 @@
-- DDL vigente para CompanyVerifactuProfile.
-- Crea la tabla fisica del perfil VeriFactu empresarial con un unico registro por company.
-- Ejecutar solo en una BD de desarrollo segura o mediante procedimiento controlado.
-- No ejecutar automaticamente en produccion.
CREATE TABLE IF NOT EXISTS company_verifactu_profiles (
id CHAR(36) NOT NULL,
company_id CHAR(36) NOT NULL,
profile_status VARCHAR(64) NOT NULL,
exemption_reason_code VARCHAR(64) NULL,
exemption_reason_detail VARCHAR(1024) NULL,
verifactu_effective_at DATETIME NULL,
auto_renew_mode VARCHAR(32) NULL,
default_tax_regime_code VARCHAR(64) NULL,
default_operation_qualification_code VARCHAR(64) NULL,
default_exemption_reason_code VARCHAR(64) NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_company_verifactu_profiles_company_id (company_id),
KEY idx_company_verifactu_profiles_status (profile_status)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
-- Validacion minima posterior a la DDL.
-- SELECT column_name
-- FROM information_schema.columns
-- WHERE table_schema = DATABASE()
-- AND table_name = 'company_verifactu_profiles';
--
-- SELECT index_name, GROUP_CONCAT(column_name ORDER BY seq_in_index) AS indexed_columns
-- FROM information_schema.statistics
-- WHERE table_schema = DATABASE()
-- AND table_name = 'company_verifactu_profiles'
-- AND index_name IN (
-- 'PRIMARY',
-- 'uq_company_verifactu_profiles_company_id',
-- 'idx_company_verifactu_profiles_status'
-- )
-- GROUP BY index_name;

View File

@ -0,0 +1,108 @@
-- DDL vigente para snapshots fiscales y de politica VeriFactu en issued_invoices.
-- Ejecutar solo en una BD de desarrollo segura o mediante procedimiento controlado.
-- No ejecutar automaticamente en produccion.
SET @target_schema = DATABASE();
SET
@ddl = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.columns
WHERE
table_schema = @target_schema
AND table_name = 'issued_invoices'
AND column_name = 'operation_qualification_code'
), 'SELECT ''operation_qualification_code already exists''', 'ALTER TABLE issued_invoices ADD COLUMN operation_qualification_code VARCHAR(64) NULL AFTER tax_regime_description'
)
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET
@ddl = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.columns
WHERE
table_schema = @target_schema
AND table_name = 'issued_invoices'
AND column_name = 'exemption_reason_code'
), 'SELECT ''exemption_reason_code already exists''', 'ALTER TABLE issued_invoices ADD COLUMN exemption_reason_code VARCHAR(64) NULL AFTER operation_qualification_code'
)
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET
@ddl = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.columns
WHERE
table_schema = @target_schema
AND table_name = 'issued_invoices'
AND column_name = 'verifactu_profile_status'
), 'SELECT ''verifactu_profile_status already exists''', 'ALTER TABLE issued_invoices ADD COLUMN verifactu_profile_status VARCHAR(64) NULL AFTER exemption_reason_code'
)
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET
@ddl = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.columns
WHERE
table_schema = @target_schema
AND table_name = 'issued_invoices'
AND column_name = 'uses_verifactu'
), 'SELECT ''uses_verifactu already exists''', 'ALTER TABLE issued_invoices ADD COLUMN uses_verifactu TINYINT(1) NULL AFTER verifactu_profile_status'
)
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET
@ddl = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.columns
WHERE
table_schema = @target_schema
AND table_name = 'issued_invoices'
AND column_name = 'verifactu_effective_at'
), 'SELECT ''verifactu_effective_at already exists''', 'ALTER TABLE issued_invoices ADD COLUMN verifactu_effective_at DATETIME NULL AFTER uses_verifactu'
)
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Validacion minima posterior a la DDL.
-- SELECT column_name
-- FROM information_schema.columns
-- WHERE table_schema = DATABASE()
-- AND table_name = 'issued_invoices'
-- AND column_name IN (
-- 'operation_qualification_code',
-- 'exemption_reason_code',
-- 'verifactu_profile_status',
-- 'uses_verifactu',
-- 'verifactu_effective_at'
-- );

View File

@ -283,6 +283,17 @@ Desde el punto de vista del módulo, la regla es:
> Registrar una route compatible con el namespace `/settings/...` y con el mecanismo que el shell defina para montar esas routes bajo `SettingsLayout`. > Registrar una route compatible con el namespace `/settings/...` y con el mecanismo que el shell defina para montar esas routes bajo `SettingsLayout`.
Implementación V1:
```txt
buildSettingsRoute(...)
→ rama /settings
→ SettingsLayout
→ buildModuleRoutes(..., pathPrefix: "settings")
```
Las routes de módulos bajo `/settings/...` se recortan y anidan bajo el shell sin crear un router paralelo.
No crear dentro del módulo: No crear dentro del módulo:
```txt ```txt

View File

@ -218,10 +218,12 @@ apps/web
│ └── rutas de módulos bajo namespace /settings/... │ └── rutas de módulos bajo namespace /settings/...
│ │
└── settings/ └── settings/
├── settings-sections.ts ├── settings-section-catalog.ts
├── build-settings-entries.ts ├── build-settings-navigation.ts
├── build-settings-routes.tsx
├── settings-layout.tsx ├── settings-layout.tsx
└── settings-navigation.tsx ├── settings-navigation.tsx
└── settings-index-page.tsx
▲ ▲
│ │
metadata ligera metadata ligera
@ -270,11 +272,13 @@ export interface ModuleSettingsEntryContribution {
path: string; path: string;
} }
export interface ModuleSettingsContribution {
entries: ModuleSettingsEntryContribution[];
}
export interface IModuleClient { export interface IModuleClient {
// contrato existente // contrato existente
settings?: { settings?: ModuleSettingsContribution;
entries: ModuleSettingsEntryContribution[];
};
} }
``` ```
@ -531,21 +535,17 @@ SettingsNavigation
## 13. Ruta raíz `/settings` ## 13. Ruta raíz `/settings`
Decisión pendiente de UX. Decisión cerrada en V1:
Opciones aceptables: ```txt
si existe al menos una entry
→ redirección determinista a la primera entry agregada
### A. Índice si no existe ninguna entry
→ estado vacío útil del shell
```
Mostrar un resumen de secciones y configuraciones disponibles. La elección sigue sin afectar al contrato de los módulos.
### B. Redirección
Redirigir a una entry predeterminada.
No cerrar esta decisión hasta implementar el shell y validar la UX.
La elección no debe afectar al contrato de los módulos.
--- ---
@ -1037,12 +1037,9 @@ La V1 es correcta si:
Pendientes de cerrar durante la implementación del shell: Pendientes de cerrar durante la implementación del shell:
1. Shape exacto de `settings` dentro de `IModuleClient`. 1. Si `order` resulta necesario en el primer segundo consumidor.
2. Cómo materializar el subtree `/settings/*` reutilizando `buildModuleRoutes()`. 2. Cuándo introducir `permission?: PermissionCode`.
3. Comportamiento exacto de `/settings`: índice o redirección. 3. Si en el futuro se necesita namespace i18n explícito.
4. Si `order` resulta necesario en el primer segundo consumidor.
5. Cuándo introducir `permission?: PermissionCode`.
6. Si en el futuro se necesita namespace i18n explícito.
Estas decisiones no deben bloquear la V1 mínima. Estas decisiones no deben bloquear la V1 mínima.

View File

@ -7,6 +7,17 @@ export type ModuleClientParams = Record<string, unknown>;
export type ModuleRouteLayout = "app-sidebar" | "auth" | "app-fullscreen"; export type ModuleRouteLayout = "app-sidebar" | "auth" | "app-fullscreen";
export interface ModuleSettingsEntryContribution {
id: string;
sectionId: string;
labelKey: string;
path: string;
}
export interface ModuleSettingsContribution {
entries: ModuleSettingsEntryContribution[];
}
export interface ModuleRouteHandle { export interface ModuleRouteHandle {
layout?: ModuleRouteLayout; layout?: ModuleRouteLayout;
protected?: boolean; protected?: boolean;
@ -33,5 +44,7 @@ export interface IModuleClient extends ModuleMetadata {
icon?: ReactNode; icon?: ReactNode;
settings?: ModuleSettingsContribution;
routes?: (params: ModuleClientParams) => ModuleRouteObject[]; routes?: (params: ModuleClientParams) => ModuleRouteObject[];
} }

View File

@ -0,0 +1,34 @@
import type { ITransactionManager } from "@erp/core/api";
import type { ICompanyVerifactuProfileRepository } from "../repositories";
import type { ICompanyVerifactuProfileResponseBuilder } from "../services";
import {
GetCompanyVerifactuProfileUseCase,
UpdateCompanyVerifactuProfileUseCase,
} from "../use-cases";
export function buildGetCompanyVerifactuProfileUseCase(deps: {
repository: ICompanyVerifactuProfileRepository;
responseBuilder: ICompanyVerifactuProfileResponseBuilder;
transactionManager: ITransactionManager;
}) {
return new GetCompanyVerifactuProfileUseCase({
repository: deps.repository,
responseBuilder: deps.responseBuilder,
transactionManager: deps.transactionManager,
});
}
export function buildUpdateCompanyVerifactuProfileUseCase(deps: {
repository: ICompanyVerifactuProfileRepository;
responseBuilder: ICompanyVerifactuProfileResponseBuilder;
transactionManager: ITransactionManager;
now: () => Date;
}) {
return new UpdateCompanyVerifactuProfileUseCase({
repository: deps.repository,
responseBuilder: deps.responseBuilder,
transactionManager: deps.transactionManager,
now: deps.now,
});
}

View File

@ -0,0 +1 @@
export * from "./company-verifactu-profile-use-cases.di";

View File

@ -0,0 +1,4 @@
export * from "./di";
export * from "./repositories";
export * from "./services";
export * from "./use-cases";

View File

@ -0,0 +1,15 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe, Result } from "@repo/rdx-utils";
import type { CompanyVerifactuProfile } from "../../../domain";
export interface ICompanyVerifactuProfileRepository {
findByCompanyId(
companyId: UniqueID,
transaction?: unknown
): Promise<Result<Maybe<CompanyVerifactuProfile>, Error>>;
create(profile: CompanyVerifactuProfile, transaction?: unknown): Promise<Result<void, Error>>;
update(profile: CompanyVerifactuProfile, transaction?: unknown): Promise<Result<void, Error>>;
}

View File

@ -0,0 +1 @@
export * from "./company-verifactu-profile.repository.interface";

View File

@ -0,0 +1,70 @@
import type { Maybe } from "@repo/rdx-utils";
import type { CompanyVerifactuProfileResponseDTO } from "../../../../common";
import type { CompanyVerifactuProfile } from "../../../domain";
import { CompanyVerifactuProfileStatus } from "../../../domain";
export interface ICompanyVerifactuProfileResponseBuilder {
build(profile: Maybe<CompanyVerifactuProfile>): CompanyVerifactuProfileResponseDTO;
}
export class CompanyVerifactuProfileResponseBuilder
implements ICompanyVerifactuProfileResponseBuilder
{
public build(profile: Maybe<CompanyVerifactuProfile>): CompanyVerifactuProfileResponseDTO {
if (profile.isNone()) {
return {
profile_status: CompanyVerifactuProfileStatus.NotConfigured,
configured: false,
uses_verifactu: false,
exemption_reason_code: null,
exemption_reason_detail: null,
verifactu_effective_at: null,
auto_renew_mode: null,
defaults: {
tax_regime_code: null,
operation_qualification_code: null,
exemption_reason_code: null,
},
updated_at: null,
};
}
const entity = profile.unwrap();
const exemptionReasonCode = entity.exemptionReasonCode.isSome()
? entity.exemptionReasonCode.unwrap()
: null;
const exemptionReasonDetail = entity.exemptionReasonDetail.isSome()
? entity.exemptionReasonDetail.unwrap()
: null;
const verifactuEffectiveAt = entity.verifactuEffectiveAt.isSome()
? entity.verifactuEffectiveAt.unwrap().toISOString()
: null;
const autoRenewMode = entity.autoRenewMode.isSome() ? entity.autoRenewMode.unwrap() : null;
const taxRegimeCode = entity.defaults.taxRegimeCode.isSome()
? entity.defaults.taxRegimeCode.unwrap()
: null;
const operationQualificationCode = entity.defaults.operationQualificationCode.isSome()
? entity.defaults.operationQualificationCode.unwrap()
: null;
const defaultExemptionReasonCode = entity.defaults.exemptionReasonCode.isSome()
? entity.defaults.exemptionReasonCode.unwrap()
: null;
return {
profile_status: entity.profileStatus,
configured: entity.isConfigured(),
uses_verifactu: entity.usesVerifactu(),
exemption_reason_code: exemptionReasonCode,
exemption_reason_detail: exemptionReasonDetail,
verifactu_effective_at: verifactuEffectiveAt,
auto_renew_mode: autoRenewMode,
defaults: {
tax_regime_code: taxRegimeCode,
operation_qualification_code: operationQualificationCode,
exemption_reason_code: defaultExemptionReasonCode,
},
updated_at: entity.updatedAt.toISOString(),
};
}
}

View File

@ -0,0 +1 @@
export * from "./company-verifactu-profile-response-builder";

View File

@ -0,0 +1,176 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { CompanyVerifactuProfile, CompanyVerifactuProfileStatus } from "../../../domain/index.ts";
import { CompanyVerifactuProfileResponseBuilder } from "../services/index.ts";
import {
GetCompanyVerifactuProfileUseCase,
UpdateCompanyVerifactuProfileUseCase,
} from "./index.ts";
function buildTransactionManager(): ITransactionManager {
return {
start: async () => undefined,
getTransaction: () => undefined,
complete: async <T>(work: (transaction: unknown) => Promise<T>) => work(undefined),
commit: async () => undefined,
rollback: async () => undefined,
};
}
test("GET devuelve not_configured cuando no existe fila", async () => {
const useCase = new GetCompanyVerifactuProfileUseCase({
repository: {
findByCompanyId: async () => Result.ok(Maybe.none()),
create: async () => Result.ok(),
update: async () => Result.ok(),
},
responseBuilder: new CompanyVerifactuProfileResponseBuilder(),
transactionManager: buildTransactionManager(),
});
const result = await useCase.execute({ companyId: UniqueID.generateNewID() });
assert.equal(result.isSuccess, true);
assert.deepEqual(result.data, {
profile_status: "not_configured",
configured: false,
uses_verifactu: false,
exemption_reason_code: null,
exemption_reason_detail: null,
verifactu_effective_at: null,
auto_renew_mode: null,
defaults: {
tax_regime_code: null,
operation_qualification_code: null,
exemption_reason_code: null,
},
updated_at: null,
});
});
test("PUT crea perfil cuando no existe fila previa", async () => {
const createdProfiles: CompanyVerifactuProfile[] = [];
const useCase = new UpdateCompanyVerifactuProfileUseCase({
repository: {
findByCompanyId: async () => Result.ok(Maybe.none()),
create: async (profile) => {
createdProfiles.push(profile);
return Result.ok();
},
update: async () => Result.ok(),
},
responseBuilder: new CompanyVerifactuProfileResponseBuilder(),
transactionManager: buildTransactionManager(),
now: () => new Date("2026-08-11T12:00:00.000Z"),
});
const result = await useCase.execute({
companyId: UniqueID.generateNewID(),
dto: {
profile_status: "obligated_with_verifactu",
exemption_reason_code: null,
exemption_reason_detail: null,
verifactu_effective_at: "2026-08-11T12:00:00.000Z",
auto_renew_mode: "enabled",
defaults: {
tax_regime_code: "01",
operation_qualification_code: "standard",
exemption_reason_code: null,
},
},
});
assert.equal(result.isSuccess, true);
assert.equal(createdProfiles.length, 1);
assert.equal(result.data.profile_status, CompanyVerifactuProfileStatus.ObligatedWithVerifactu);
assert.equal(result.data.uses_verifactu, true);
});
test("PUT reconfigura perfil existente", async () => {
const existingProfileResult = CompanyVerifactuProfile.create({
companyId: UniqueID.generateNewID(),
profileStatus: CompanyVerifactuProfileStatus.Exempt,
exemptionReasonCode: Maybe.some("EX-01"),
exemptionReasonDetail: Maybe.none(),
verifactuEffectiveAt: Maybe.none(),
autoRenewMode: Maybe.none(),
defaults: CompanyVerifactuProfile.buildEmptyDefaults(),
createdAt: new Date("2026-08-11T08:00:00.000Z"),
updatedAt: new Date("2026-08-11T08:00:00.000Z"),
});
assert.equal(existingProfileResult.isSuccess, true);
let updatedCalled = false;
const useCase = new UpdateCompanyVerifactuProfileUseCase({
repository: {
findByCompanyId: async () => Result.ok(Maybe.some(existingProfileResult.data)),
create: async () => Result.ok(),
update: async () => {
updatedCalled = true;
return Result.ok();
},
},
responseBuilder: new CompanyVerifactuProfileResponseBuilder(),
transactionManager: buildTransactionManager(),
now: () => new Date("2026-08-11T13:00:00.000Z"),
});
const result = await useCase.execute({
companyId: existingProfileResult.data.companyId,
dto: {
profile_status: "obligated_without_verifactu",
exemption_reason_code: null,
exemption_reason_detail: null,
verifactu_effective_at: null,
auto_renew_mode: null,
defaults: {
tax_regime_code: "01",
operation_qualification_code: null,
exemption_reason_code: null,
},
},
});
assert.equal(result.isSuccess, true);
assert.equal(updatedCalled, true);
assert.equal(result.data.profile_status, CompanyVerifactuProfileStatus.ObligatedWithoutVerifactu);
});
test("PUT falla cuando la combinación viola invariantes de dominio", async () => {
const useCase = new UpdateCompanyVerifactuProfileUseCase({
repository: {
findByCompanyId: async () => Result.ok(Maybe.none()),
create: async () => Result.ok(),
update: async () => Result.ok(),
},
responseBuilder: new CompanyVerifactuProfileResponseBuilder(),
transactionManager: buildTransactionManager(),
now: () => new Date("2026-08-11T13:00:00.000Z"),
});
const result = await useCase.execute({
companyId: UniqueID.generateNewID(),
dto: {
profile_status: "exempt",
exemption_reason_code: "EX-01",
exemption_reason_detail: null,
verifactu_effective_at: "2026-08-11T13:00:00.000Z",
auto_renew_mode: null,
defaults: {
tax_regime_code: null,
operation_qualification_code: null,
exemption_reason_code: null,
},
},
});
assert.equal(result.isFailure, true);
assert.match(result.error.message, /cannot activate verifactu/i);
});

View File

@ -0,0 +1,35 @@
import type { ITransactionManager } from "@erp/core/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { CompanyVerifactuProfileResponseDTO } from "../../../../common";
import type { ICompanyVerifactuProfileRepository } from "../repositories";
import type { ICompanyVerifactuProfileResponseBuilder } from "../services";
type GetCompanyVerifactuProfileUseCaseInput = {
companyId: UniqueID;
};
export class GetCompanyVerifactuProfileUseCase {
public constructor(
private readonly deps: {
repository: ICompanyVerifactuProfileRepository;
responseBuilder: ICompanyVerifactuProfileResponseBuilder;
transactionManager: ITransactionManager;
}
) {}
public async execute(
params: GetCompanyVerifactuProfileUseCaseInput
): Promise<Result<CompanyVerifactuProfileResponseDTO, Error>> {
return this.deps.transactionManager.complete(async (transaction: unknown) => {
const profileResult = await this.deps.repository.findByCompanyId(params.companyId, transaction);
if (profileResult.isFailure) {
return Result.fail(profileResult.error);
}
return Result.ok(this.deps.responseBuilder.build(profileResult.data));
});
}
}

View File

@ -0,0 +1,2 @@
export * from "./get-company-verifactu-profile.use-case";
export * from "./update-company-verifactu-profile.use-case";

View File

@ -0,0 +1,139 @@
import type { ITransactionManager } from "@erp/core/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type {
CompanyVerifactuProfileResponseDTO,
UpdateCompanyVerifactuProfileRequestDTO,
} from "../../../../common";
import {
type AutoRenewMode,
CompanyVerifactuProfile,
type CompanyVerifactuProfileProps,
type CompanyVerifactuProfileReconfiguration,
} from "../../../domain";
import type { ICompanyVerifactuProfileRepository } from "../repositories";
import type { ICompanyVerifactuProfileResponseBuilder } from "../services";
type UpdateCompanyVerifactuProfileUseCaseInput = {
companyId: UniqueID;
dto: UpdateCompanyVerifactuProfileRequestDTO;
};
export class UpdateCompanyVerifactuProfileUseCase {
public constructor(
private readonly deps: {
repository: ICompanyVerifactuProfileRepository;
responseBuilder: ICompanyVerifactuProfileResponseBuilder;
transactionManager: ITransactionManager;
now: () => Date;
}
) {}
public async execute(
params: UpdateCompanyVerifactuProfileUseCaseInput
): Promise<Result<CompanyVerifactuProfileResponseDTO, Error>> {
const { companyId, dto } = params;
return this.deps.transactionManager.complete(async (transaction: unknown) => {
const existingResult = await this.deps.repository.findByCompanyId(companyId, transaction);
if (existingResult.isFailure) {
return Result.fail(existingResult.error);
}
const now = this.deps.now();
const reconfiguration = this.mapReconfiguration(dto);
if (existingResult.data.isNone()) {
const createResult = CompanyVerifactuProfile.create(
this.buildCreateProps({ companyId, reconfiguration, now })
);
if (createResult.isFailure) {
return Result.fail(createResult.error);
}
const saveResult = await this.deps.repository.create(createResult.data, transaction);
if (saveResult.isFailure) {
return Result.fail(saveResult.error);
}
return Result.ok(this.deps.responseBuilder.build(Maybe.some(createResult.data)));
}
const profile = existingResult.data.unwrap();
const updateResult = profile.reconfigure(reconfiguration, now);
if (updateResult.isFailure) {
return Result.fail(updateResult.error);
}
const saveResult = await this.deps.repository.update(profile, transaction);
if (saveResult.isFailure) {
return Result.fail(saveResult.error);
}
return Result.ok(this.deps.responseBuilder.build(Maybe.some(profile)));
});
}
private buildCreateProps(params: {
companyId: UniqueID;
reconfiguration: CompanyVerifactuProfileReconfiguration;
now: Date;
}): CompanyVerifactuProfileProps {
return {
companyId: params.companyId,
...params.reconfiguration,
createdAt: params.now,
updatedAt: params.now,
};
}
private mapReconfiguration(
dto: UpdateCompanyVerifactuProfileRequestDTO
): CompanyVerifactuProfileReconfiguration {
const exemptionReasonCode =
dto.exemption_reason_code == null
? Maybe.none<string>()
: Maybe.some(dto.exemption_reason_code);
const exemptionReasonDetail =
dto.exemption_reason_detail == null
? Maybe.none<string>()
: Maybe.some(dto.exemption_reason_detail);
const verifactuEffectiveAt =
dto.verifactu_effective_at == null
? Maybe.none<Date>()
: Maybe.some(new Date(dto.verifactu_effective_at));
const autoRenewMode =
dto.auto_renew_mode == null ? Maybe.none<AutoRenewMode>() : Maybe.some(dto.auto_renew_mode);
const taxRegimeCode =
dto.defaults.tax_regime_code == null
? Maybe.none<string>()
: Maybe.some(dto.defaults.tax_regime_code);
const operationQualificationCode =
dto.defaults.operation_qualification_code == null
? Maybe.none<string>()
: Maybe.some(dto.defaults.operation_qualification_code);
const defaultExemptionReasonCode =
dto.defaults.exemption_reason_code == null
? Maybe.none<string>()
: Maybe.some(dto.defaults.exemption_reason_code);
return {
profileStatus: dto.profile_status,
exemptionReasonCode,
exemptionReasonDetail,
verifactuEffectiveAt,
autoRenewMode,
defaults: {
taxRegimeCode,
operationQualificationCode,
exemptionReasonCode: defaultExemptionReasonCode,
},
};
}
}

View File

@ -1,3 +1,4 @@
export * from "./company-verifactu-profile";
export * from "./invoice-series"; export * from "./invoice-series";
export * from "./issued-invoices"; export * from "./issued-invoices";
export * from "./proformas"; export * from "./proformas";

View File

@ -1,3 +1,4 @@
import type { CompanyVerifactuProfileStatus } from "../../../domain";
import type { Proforma } from "../../../domain"; import type { Proforma } from "../../../domain";
import type { import type {
InvocieTaxRegimeFullReadModel, InvocieTaxRegimeFullReadModel,
@ -14,4 +15,13 @@ export interface ProformaIssueReadModel {
proforma: Proforma; proforma: Proforma;
paymentMethod: InvoicePaymentMethodReadModel; paymentMethod: InvoicePaymentMethodReadModel;
taxRegime: InvocieTaxRegimeFullReadModel; taxRegime: InvocieTaxRegimeFullReadModel;
fiscalClassification: {
operationQualificationCode: string | null;
exemptionReasonCode: string | null;
};
verifactuPolicy: {
profileStatus: CompanyVerifactuProfileStatus;
usesVerifactu: boolean;
effectiveAt: Date | null;
};
} }

View File

@ -5,3 +5,4 @@ export * from "./issued-invoice-document-properties-factory";
export * from "./issued-invoice-finder"; export * from "./issued-invoice-finder";
export * from "./issued-invoice-public-services.interface"; export * from "./issued-invoice-public-services.interface";
export * from "./proforma-to-issued-invoice-props-converter"; export * from "./proforma-to-issued-invoice-props-converter";
export * from "./resolve-proforma-issue-verifactu-snapshots";

View File

@ -9,10 +9,12 @@ import {
type InvoiceSerie, type InvoiceSerie,
InvoiceStatus, InvoiceStatus,
InvoiceTaxRegime, InvoiceTaxRegime,
IssuedInvoiceFiscalClassification,
IssuedInvoiceItem, IssuedInvoiceItem,
IssuedInvoiceRecipient, IssuedInvoiceRecipient,
IssuedInvoiceTax, IssuedInvoiceTax,
IssuedInvoiceTaxes, IssuedInvoiceTaxes,
IssuedInvoiceVerifactuPolicy,
type Proforma, type Proforma,
} from "../../../domain"; } from "../../../domain";
import type { ProformaIssueReadModel } from "../models"; import type { ProformaIssueReadModel } from "../models";
@ -70,6 +72,18 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
return Result.fail(taxRegimeResult.error); return Result.fail(taxRegimeResult.error);
} }
const fiscalClassificationResult = this.resolveFiscalClassification(source);
if (fiscalClassificationResult.isFailure) {
return Result.fail(fiscalClassificationResult.error);
}
const verifactuPolicyResult = this.resolveVerifactuPolicy(source);
if (verifactuPolicyResult.isFailure) {
return Result.fail(verifactuPolicyResult.error);
}
const proformaTotals = proforma.totals(); const proformaTotals = proforma.totals();
return Result.ok({ return Result.ok({
@ -92,6 +106,8 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
paymentMethod: paymentResult.data, paymentMethod: paymentResult.data,
taxRegime: taxRegimeResult.data, taxRegime: taxRegimeResult.data,
fiscalClassification: Maybe.some(fiscalClassificationResult.data),
verifactuPolicy: Maybe.some(verifactuPolicyResult.data),
customerId: proforma.customerId, customerId: proforma.customerId,
recipient: recipientResult.data, recipient: recipientResult.data,
@ -278,4 +294,48 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
return Result.ok(taxRegimeResult.data); return Result.ok(taxRegimeResult.data);
} }
private resolveFiscalClassification(
source: ProformaIssueReadModel
): Result<IssuedInvoiceFiscalClassification, Error> {
return IssuedInvoiceFiscalClassification.create({
taxRegimeCode: source.taxRegime.code,
taxRegimeLabel:
source.taxRegime.description.trim().length > 0
? Maybe.some(source.taxRegime.description)
: Maybe.none(),
operationQualificationCode:
source.fiscalClassification.operationQualificationCode == null
? Maybe.none()
: Maybe.some(source.fiscalClassification.operationQualificationCode),
exemptionReasonCode:
source.fiscalClassification.exemptionReasonCode == null
? Maybe.none()
: Maybe.some(source.fiscalClassification.exemptionReasonCode),
});
}
private resolveVerifactuPolicy(
source: ProformaIssueReadModel
): Result<IssuedInvoiceVerifactuPolicy, Error> {
let effectiveAt = Maybe.none<UtcDate>();
if (source.verifactuPolicy.effectiveAt != null) {
const effectiveAtResult = UtcDate.createFromISO(
source.verifactuPolicy.effectiveAt.toISOString()
);
if (effectiveAtResult.isFailure) {
return Result.fail(effectiveAtResult.error);
}
effectiveAt = Maybe.some(effectiveAtResult.data);
}
return IssuedInvoiceVerifactuPolicy.create({
profileStatus: source.verifactuPolicy.profileStatus,
usesVerifactu: source.verifactuPolicy.usesVerifactu,
effectiveAt,
});
}
} }

View File

@ -0,0 +1,129 @@
import assert from "node:assert/strict";
import test from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import {
CompanyVerifactuProfile,
CompanyVerifactuProfileStatus,
} from "../../../domain";
import { resolveProformaIssueVerifactuSnapshots } from "./resolve-proforma-issue-verifactu-snapshots";
function buildCompanyProfile(params?: {
profileStatus?: CompanyVerifactuProfileStatus;
taxRegimeCode?: string | null;
operationQualificationCode?: string | null;
exemptionReasonCode?: string | null;
verifactuEffectiveAt?: Date | null;
}) {
const result = CompanyVerifactuProfile.create(
{
companyId: UniqueID.generateNewID(),
profileStatus:
params?.profileStatus ?? CompanyVerifactuProfileStatus.ObligatedWithVerifactu,
exemptionReasonCode: Maybe.none(),
exemptionReasonDetail: Maybe.none(),
verifactuEffectiveAt:
params?.verifactuEffectiveAt == null
? Maybe.none()
: Maybe.some(params.verifactuEffectiveAt),
autoRenewMode:
params?.verifactuEffectiveAt == null ? Maybe.none() : Maybe.some("enabled"),
defaults: {
taxRegimeCode:
params?.taxRegimeCode == null ? Maybe.none() : Maybe.some(params.taxRegimeCode),
operationQualificationCode:
params?.operationQualificationCode == null
? Maybe.none()
: Maybe.some(params.operationQualificationCode),
exemptionReasonCode:
params?.exemptionReasonCode == null
? Maybe.none()
: Maybe.some(params.exemptionReasonCode),
},
createdAt: new Date("2026-08-12T10:00:00.000Z"),
updatedAt: new Date("2026-08-12T10:00:00.000Z"),
},
UniqueID.generateNewID()
);
if (result.isFailure) {
throw result.error;
}
return result.data;
}
test("resolveProformaIssueVerifactuSnapshots prioriza document over customer and company", () => {
const profile = buildCompanyProfile({
taxRegimeCode: "03",
operationQualificationCode: "op-company",
exemptionReasonCode: "ex-company",
verifactuEffectiveAt: new Date("2026-08-15T00:00:00.000Z"),
});
const result = resolveProformaIssueVerifactuSnapshots({
proformaTaxRegimeCode: Maybe.some("01"),
customerTaxRegimeCode: Maybe.some("02"),
companyProfile: profile,
});
assert.equal(result.taxRegimeCode.unwrap(), "01");
assert.equal(result.fiscalClassification.operationQualificationCode.unwrap(), "op-company");
assert.equal(result.fiscalClassification.exemptionReasonCode.unwrap(), "ex-company");
});
test("resolveProformaIssueVerifactuSnapshots usa customer cuando documento no aporta tax regime", () => {
const profile = buildCompanyProfile({
taxRegimeCode: "03",
profileStatus: CompanyVerifactuProfileStatus.ObligatedWithoutVerifactu,
});
const result = resolveProformaIssueVerifactuSnapshots({
proformaTaxRegimeCode: Maybe.none(),
customerTaxRegimeCode: Maybe.some("02"),
companyProfile: profile,
});
assert.equal(result.taxRegimeCode.unwrap(), "02");
assert.equal(result.verifactuPolicy.profileStatus, "obligated_without_verifactu");
assert.equal(result.verifactuPolicy.usesVerifactu, false);
assert.equal(result.verifactuPolicy.effectiveAt.isNone(), true);
});
test("resolveProformaIssueVerifactuSnapshots usa company defaults y politica cuando faltan documento y customer", () => {
const effectiveAt = new Date("2026-08-20T08:30:00.000Z");
const profile = buildCompanyProfile({
taxRegimeCode: "05",
operationQualificationCode: "op-company",
exemptionReasonCode: "ex-company",
verifactuEffectiveAt: effectiveAt,
});
const result = resolveProformaIssueVerifactuSnapshots({
proformaTaxRegimeCode: Maybe.none(),
customerTaxRegimeCode: Maybe.none(),
companyProfile: profile,
});
assert.equal(result.taxRegimeCode.unwrap(), "05");
assert.equal(result.verifactuPolicy.profileStatus, "obligated_with_verifactu");
assert.equal(result.verifactuPolicy.usesVerifactu, true);
assert.equal(result.verifactuPolicy.effectiveAt.unwrap(), effectiveAt);
});
test("resolveProformaIssueVerifactuSnapshots mantiene comportamiento legacy sin perfil", () => {
const result = resolveProformaIssueVerifactuSnapshots({
proformaTaxRegimeCode: Maybe.none(),
customerTaxRegimeCode: Maybe.none(),
companyProfile: null,
});
assert.equal(result.taxRegimeCode.isNone(), true);
assert.equal(result.fiscalClassification.operationQualificationCode.isNone(), true);
assert.equal(result.fiscalClassification.exemptionReasonCode.isNone(), true);
assert.equal(result.verifactuPolicy.profileStatus, "not_configured");
assert.equal(result.verifactuPolicy.usesVerifactu, false);
assert.equal(result.verifactuPolicy.effectiveAt.isNone(), true);
});

View File

@ -0,0 +1,69 @@
import { Maybe } from "@repo/rdx-utils";
import type { CompanyVerifactuProfile } from "../../../domain";
import { CompanyVerifactuProfileStatus } from "../../../domain";
export interface ResolveProformaIssueVerifactuSnapshotsInput {
proformaTaxRegimeCode: Maybe<string>;
customerTaxRegimeCode: Maybe<string>;
companyProfile: CompanyVerifactuProfile | null;
}
export interface ResolveProformaIssueVerifactuSnapshotsOutput {
taxRegimeCode: Maybe<string>;
fiscalClassification: {
operationQualificationCode: Maybe<string>;
exemptionReasonCode: Maybe<string>;
};
verifactuPolicy: {
profileStatus: CompanyVerifactuProfileStatus;
usesVerifactu: boolean;
effectiveAt: Maybe<Date>;
};
}
/**
* Resuelve la clasificación histórica que debe snapshotearse en emisión.
*
* Mantiene la precedencia real soportada hoy por el backend:
* - taxRegimeCode: documento -> customer -> company -> sin valor
* - operationQualificationCode: company -> sin valor
* - exemptionReasonCode: company default -> sin valor
*/
export function resolveProformaIssueVerifactuSnapshots(
input: ResolveProformaIssueVerifactuSnapshotsInput
): ResolveProformaIssueVerifactuSnapshotsOutput {
const companyProfile = input.companyProfile;
const taxRegimeCode = input.proformaTaxRegimeCode.isSome()
? input.proformaTaxRegimeCode
: input.customerTaxRegimeCode.isSome()
? input.customerTaxRegimeCode
: companyProfile?.defaults.taxRegimeCode ?? Maybe.none<string>();
const operationQualificationCode =
companyProfile?.defaults.operationQualificationCode ?? Maybe.none<string>();
const exemptionReasonCode =
companyProfile?.defaults.exemptionReasonCode ?? Maybe.none<string>();
const profileStatus = companyProfile?.profileStatus ?? CompanyVerifactuProfileStatus.NotConfigured;
const usesVerifactu =
profileStatus === CompanyVerifactuProfileStatus.ObligatedWithVerifactu;
const effectiveAt =
usesVerifactu && companyProfile?.verifactuEffectiveAt.isSome()
? companyProfile.verifactuEffectiveAt
: Maybe.none<Date>();
return {
taxRegimeCode,
fiscalClassification: {
operationQualificationCode,
exemptionReasonCode,
},
verifactuPolicy: {
profileStatus,
usesVerifactu,
effectiveAt,
},
};
}

View File

@ -38,6 +38,13 @@ export class IssuedInvoiceFullSnapshotBuilder implements IIssuedInvoiceFullSnaps
description: invoice.taxRegime.description, description: invoice.taxRegime.description,
}; };
const fiscal_classification = maybeToNullable(invoice.fiscalClassification, (value) =>
value.toObjectString()
);
const verifactu_policy = maybeToNullable(invoice.verifactuPolicy, (value) =>
value.toObjectString()
);
return { return {
id: invoice.id.toString(), id: invoice.id.toString(),
company_id: invoice.companyId.toString(), company_id: invoice.companyId.toString(),
@ -68,6 +75,8 @@ export class IssuedInvoiceFullSnapshotBuilder implements IIssuedInvoiceFullSnaps
payment_method, payment_method,
tax_regime, tax_regime,
fiscal_classification,
verifactu_policy,
subtotal_amount: invoice.subtotalAmount.toObjectString(), subtotal_amount: invoice.subtotalAmount.toObjectString(),

View File

@ -51,6 +51,19 @@ export interface IIssuedInvoiceFullSnapshot {
code: string; code: string;
description: string | null; description: string | null;
}; };
fiscal_classification: {
tax_regime_code: string;
tax_regime_label: string | null;
operation_qualification_code: string | null;
operation_qualification_label: string | null;
exemption_reason_code: string | null;
exemption_reason_label: string | null;
} | null;
verifactu_policy: {
profile_status: string;
uses_verifactu: boolean;
effective_at: string | null;
} | null;
subtotal_amount: { value: string; scale: string; currency_code: string }; subtotal_amount: { value: string; scale: string; currency_code: string };
@ -67,7 +80,6 @@ export interface IIssuedInvoiceFullSnapshot {
taxes_amount: { value: string; scale: string; currency_code: string }; taxes_amount: { value: string; scale: string; currency_code: string };
total_amount: { value: string; scale: string; currency_code: string }; total_amount: { value: string; scale: string; currency_code: string };
verifactu: IIssuedInvoiceVerifactuFullSnapshot; verifactu: IIssuedInvoiceVerifactuFullSnapshot;
items: IIssuedInvoiceItemFullSnapshot[]; items: IIssuedInvoiceItemFullSnapshot[];

View File

@ -1,5 +1,5 @@
import { type IProformaDeleter, ProformaDeleter } from "../services";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
import { type IProformaDeleter, ProformaDeleter } from "../services";
export function buildProformaDeleter(deps: { repository: IProformaRepository }): IProformaDeleter { export function buildProformaDeleter(deps: { repository: IProformaRepository }): IProformaDeleter {
return new ProformaDeleter({ return new ProformaDeleter({

View File

@ -1,6 +1,8 @@
import type { IPaymentMethodPublicFinder, ITaxRegimePublicFinder } from "@erp/catalogs/api"; import type { IPaymentMethodPublicFinder, ITaxRegimePublicFinder } from "@erp/catalogs/api";
import type { ICustomerPublicServices } from "@erp/customers/api";
import type { IProformaToIssuedInvoiceConverter } from "../../issued-invoices"; import type { IProformaToIssuedInvoiceConverter } from "../../issued-invoices";
import type { ICompanyVerifactuProfileRepository } from "../../company-verifactu-profile";
import { type IProformaIssuer, ProformaIssueReadModelAssembler, ProformaIssuer } from "../services"; import { type IProformaIssuer, ProformaIssueReadModelAssembler, ProformaIssuer } from "../services";
export const buildProformaIssuer = (params: { export const buildProformaIssuer = (params: {
@ -14,9 +16,13 @@ export const buildProformaIssuer = (params: {
export const buildProformaIssueReadModelAssembler = (params: { export const buildProformaIssueReadModelAssembler = (params: {
paymentMethodFinder: IPaymentMethodPublicFinder; paymentMethodFinder: IPaymentMethodPublicFinder;
taxRegimeFinder: ITaxRegimePublicFinder; taxRegimeFinder: ITaxRegimePublicFinder;
customerServices: ICustomerPublicServices;
companyVerifactuProfileRepository: ICompanyVerifactuProfileRepository;
}) => { }) => {
return new ProformaIssueReadModelAssembler({ return new ProformaIssueReadModelAssembler({
paymentMethodFinder: params.paymentMethodFinder, paymentMethodFinder: params.paymentMethodFinder,
taxRegimeFinder: params.taxRegimeFinder, taxRegimeFinder: params.taxRegimeFinder,
customerServices: params.customerServices,
companyVerifactuProfileRepository: params.companyVerifactuProfileRepository,
}); });
}; };

View File

@ -4,10 +4,10 @@ import type { IIssuedInvoicePublicServices } from "../../issued-invoices";
import type { ICreateProformaInputMapper, IUpdateProformaInputMapper } from "../mappers"; import type { ICreateProformaInputMapper, IUpdateProformaInputMapper } from "../mappers";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
import type { import type {
IProformaArchiver,
ICompanyReportProfileFinder, ICompanyReportProfileFinder,
IProformaDeleter, IProformaArchiver,
IProformaCreator, IProformaCreator,
IProformaDeleter,
IProformaFinder, IProformaFinder,
IProformaFullReadModelAssembler, IProformaFullReadModelAssembler,
IProformaIssueReadModelAssembler, IProformaIssueReadModelAssembler,
@ -17,8 +17,8 @@ import type {
ProformaDocumentGeneratorService, ProformaDocumentGeneratorService,
} from "../services"; } from "../services";
import type { import type {
IProformaFullSnapshotBuilder,
IIssueProformaResponseSnapshotBuilder, IIssueProformaResponseSnapshotBuilder,
IProformaFullSnapshotBuilder,
IProformaReportSnapshotBuilder, IProformaReportSnapshotBuilder,
IProformaSummarySnapshotBuilder, IProformaSummarySnapshotBuilder,
} from "../snapshot-builders"; } from "../snapshot-builders";
@ -26,8 +26,8 @@ import {
ArchiveProformaByIdUseCase, ArchiveProformaByIdUseCase,
ChangeStatusProformaUseCase, ChangeStatusProformaUseCase,
CreateProformaUseCase, CreateProformaUseCase,
DuplicateProformaByIdUseCase,
DeleteProformaByIdUseCase, DeleteProformaByIdUseCase,
DuplicateProformaByIdUseCase,
GetProformaByIdUseCase, GetProformaByIdUseCase,
IssueProformaUseCase, IssueProformaUseCase,
ListProformasUseCase, ListProformasUseCase,

View File

@ -1,9 +1,12 @@
import type { IPaymentMethodPublicFinder, ITaxRegimePublicFinder } from "@erp/catalogs/api"; import type { IPaymentMethodPublicFinder, ITaxRegimePublicFinder } from "@erp/catalogs/api";
import type { ICustomerPublicServices } from "@erp/customers/api";
import type { UniqueID } from "@repo/rdx-ddd"; import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { type Maybe, Result } from "@repo/rdx-utils";
import type { Proforma } from "../../../../domain"; import type { Proforma } from "../../../../domain";
import type { ICompanyVerifactuProfileRepository } from "../../../company-verifactu-profile";
import type { ProformaIssueReadModel } from "../../../issued-invoices"; import type { ProformaIssueReadModel } from "../../../issued-invoices";
import { resolveProformaIssueVerifactuSnapshots } from "../../../issued-invoices";
export interface IProformaIssueReadModelAssembler { export interface IProformaIssueReadModelAssembler {
assemble(params: { assemble(params: {
@ -24,6 +27,8 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA
private readonly deps: { private readonly deps: {
paymentMethodFinder: IPaymentMethodPublicFinder; paymentMethodFinder: IPaymentMethodPublicFinder;
taxRegimeFinder: ITaxRegimePublicFinder; taxRegimeFinder: ITaxRegimePublicFinder;
customerServices: ICustomerPublicServices;
companyVerifactuProfileRepository: ICompanyVerifactuProfileRepository;
} }
) {} ) {}
@ -48,7 +53,35 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA
return Result.fail(paymentMethodResult.error); return Result.fail(paymentMethodResult.error);
} }
const taxRegimeResult = await this.resolveTaxRegime(params); const customerResult = await this.deps.customerServices.getCustomerById(params.proforma.customerId, {
companyId: params.companyId,
transaction: params.transaction,
});
if (customerResult.isFailure) {
return Result.fail(customerResult.error);
}
const companyProfileResult =
await this.deps.companyVerifactuProfileRepository.findByCompanyId(
params.companyId,
params.transaction
);
if (companyProfileResult.isFailure) {
return Result.fail(companyProfileResult.error);
}
const resolution = resolveProformaIssueVerifactuSnapshots({
proformaTaxRegimeCode: params.proforma.taxRegimeCode,
customerTaxRegimeCode: customerResult.data.taxRegimeCode,
companyProfile: companyProfileResult.data.getOrUndefined() ?? null,
});
const taxRegimeResult = await this.resolveTaxRegime({
...params,
taxRegimeCode: resolution.taxRegimeCode,
});
if (taxRegimeResult.isFailure) { if (taxRegimeResult.isFailure) {
return Result.fail(taxRegimeResult.error); return Result.fail(taxRegimeResult.error);
@ -64,19 +97,31 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA
description: paymentMethod.description, description: paymentMethod.description,
}, },
taxRegime: taxRegimeResult.data, taxRegime: taxRegimeResult.data,
fiscalClassification: {
operationQualificationCode:
resolution.fiscalClassification.operationQualificationCode.getOrUndefined() ?? null,
exemptionReasonCode:
resolution.fiscalClassification.exemptionReasonCode.getOrUndefined() ?? null,
},
verifactuPolicy: {
profileStatus: resolution.verifactuPolicy.profileStatus,
usesVerifactu: resolution.verifactuPolicy.usesVerifactu,
effectiveAt: resolution.verifactuPolicy.effectiveAt.getOrUndefined() ?? null,
},
}); });
} }
private async resolveTaxRegime(params: { private async resolveTaxRegime(params: {
companyId: UniqueID; companyId: UniqueID;
proforma: Proforma; proforma: Proforma;
taxRegimeCode: Maybe<string>;
transaction?: unknown; transaction?: unknown;
}) { }) {
if (params.proforma.taxRegimeCode.isNone()) { if (params.taxRegimeCode.isNone()) {
return Result.fail(new Error("Tax regime is required to issue proforma")); return Result.fail(new Error("Tax regime is required to issue proforma"));
} }
const taxRegimeCode = params.proforma.taxRegimeCode.unwrap(); const taxRegimeCode = params.taxRegimeCode.unwrap();
const taxRegimeResult = await this.deps.taxRegimeFinder.findByCodeInCompany({ const taxRegimeResult = await this.deps.taxRegimeFinder.findByCodeInCompany({
companyId: params.companyId, companyId: params.companyId,

View File

@ -1,4 +1,5 @@
import type { ISnapshotBuilder } from "@erp/core/api"; import type { ISnapshotBuilder } from "@erp/core/api";
import { maybeToNullable } from "@repo/rdx-ddd";
import type { IssueProformaByIdResponseDTO } from "../../../../../common"; import type { IssueProformaByIdResponseDTO } from "../../../../../common";
import type { IssuedInvoice } from "../../../../domain"; import type { IssuedInvoice } from "../../../../domain";
@ -27,6 +28,12 @@ export class IssueProformaResponseSnapshotBuilder implements IIssueProformaRespo
code: invoice.taxRegime.code, code: invoice.taxRegime.code,
description: invoice.taxRegime.description, description: invoice.taxRegime.description,
}, },
fiscal_classification: maybeToNullable(invoice.fiscalClassification, (value) =>
value.toObjectString()
),
verifactu_policy: maybeToNullable(invoice.verifactuPolicy, (value) =>
value.toObjectString()
),
totals: { totals: {
taxable_base: invoice.taxableAmount.toObjectString(), taxable_base: invoice.taxableAmount.toObjectString(),
tax_total: invoice.taxesAmount.toObjectString(), tax_total: invoice.taxesAmount.toObjectString(),

View File

@ -0,0 +1,122 @@
import assert from "node:assert/strict";
import test from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import {
CompanyVerifactuProfile,
type CompanyVerifactuProfileProps,
CompanyVerifactuProfileStatus,
} from "./index.ts";
function buildProps(
overrides: Partial<CompanyVerifactuProfileProps> = {}
): CompanyVerifactuProfileProps {
const now = new Date("2026-08-11T10:00:00.000Z");
return {
companyId: UniqueID.generateNewID(),
profileStatus: CompanyVerifactuProfileStatus.NotConfigured,
exemptionReasonCode: Maybe.none(),
exemptionReasonDetail: Maybe.none(),
verifactuEffectiveAt: Maybe.none(),
autoRenewMode: Maybe.none(),
defaults: CompanyVerifactuProfile.buildEmptyDefaults(),
createdAt: now,
updatedAt: now,
...overrides,
};
}
test("create acepta un perfil not_configured sin valores efectivos", () => {
const result = CompanyVerifactuProfile.create(buildProps());
assert.equal(result.isSuccess, true);
assert.equal(result.data.profileStatus, CompanyVerifactuProfileStatus.NotConfigured);
assert.equal(result.data.isConfigured(), false);
assert.equal(result.data.usesVerifactu(), false);
});
test("create rechaza defaults efectivos en not_configured", () => {
const result = CompanyVerifactuProfile.create(
buildProps({
defaults: {
taxRegimeCode: Maybe.some("01"),
operationQualificationCode: Maybe.none(),
exemptionReasonCode: Maybe.none(),
},
})
);
assert.equal(result.isFailure, true);
assert.match(result.error.message, /effective fiscal defaults/i);
});
test("create rechaza activación VeriFactu en exempt", () => {
const result = CompanyVerifactuProfile.create(
buildProps({
profileStatus: CompanyVerifactuProfileStatus.Exempt,
exemptionReasonCode: Maybe.some("EX-01"),
verifactuEffectiveAt: Maybe.some(new Date("2026-08-11T00:00:00.000Z")),
})
);
assert.equal(result.isFailure, true);
assert.match(result.error.message, /cannot activate verifactu/i);
});
test("create rechaza auto renew en obligated_without_verifactu", () => {
const result = CompanyVerifactuProfile.create(
buildProps({
profileStatus: CompanyVerifactuProfileStatus.ObligatedWithoutVerifactu,
autoRenewMode: Maybe.some("enabled"),
})
);
assert.equal(result.isFailure, true);
assert.match(result.error.message, /cannot define auto renew mode/i);
});
test("create exige fecha efectiva en obligated_with_verifactu", () => {
const result = CompanyVerifactuProfile.create(
buildProps({
profileStatus: CompanyVerifactuProfileStatus.ObligatedWithVerifactu,
})
);
assert.equal(result.isFailure, true);
assert.match(result.error.message, /must define an effective date/i);
});
test("reconfigure permite pasar de exempt a obligated_with_verifactu con invariantes válidas", () => {
const createResult = CompanyVerifactuProfile.create(
buildProps({
profileStatus: CompanyVerifactuProfileStatus.Exempt,
exemptionReasonCode: Maybe.some("EX-01"),
})
);
assert.equal(createResult.isSuccess, true);
const profile = createResult.data;
const updateResult = profile.reconfigure(
{
profileStatus: CompanyVerifactuProfileStatus.ObligatedWithVerifactu,
exemptionReasonCode: Maybe.none(),
exemptionReasonDetail: Maybe.none(),
verifactuEffectiveAt: Maybe.some(new Date("2026-08-11T12:00:00.000Z")),
autoRenewMode: Maybe.some("enabled"),
defaults: {
taxRegimeCode: Maybe.some("01"),
operationQualificationCode: Maybe.some("standard"),
exemptionReasonCode: Maybe.none(),
},
},
new Date("2026-08-11T12:00:00.000Z")
);
assert.equal(updateResult.isSuccess, true);
assert.equal(profile.profileStatus, CompanyVerifactuProfileStatus.ObligatedWithVerifactu);
assert.equal(profile.usesVerifactu(), true);
});

View File

@ -0,0 +1,252 @@
import { AggregateRoot, DomainValidationError, type UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import {
type AutoRenewMode,
type CompanyVerifactuDefaults,
type CompanyVerifactuProfileReconfiguration,
CompanyVerifactuProfileStatus,
type CompanyVerifactuProfileStatus as CompanyVerifactuProfileStatusType,
} from "./company-verifactu-profile.types";
export interface CompanyVerifactuProfileProps {
companyId: UniqueID;
profileStatus: CompanyVerifactuProfileStatusType;
exemptionReasonCode: Maybe<string>;
exemptionReasonDetail: Maybe<string>;
verifactuEffectiveAt: Maybe<Date>;
autoRenewMode: Maybe<AutoRenewMode>;
defaults: CompanyVerifactuDefaults;
createdAt: Date;
updatedAt: Date;
}
type CompanyVerifactuProfileInternalProps = CompanyVerifactuProfileProps;
export class CompanyVerifactuProfile extends AggregateRoot<CompanyVerifactuProfileInternalProps> {
protected constructor(props: CompanyVerifactuProfileInternalProps, id?: UniqueID) {
super(props, id);
}
public static create(
props: CompanyVerifactuProfileProps,
id?: UniqueID
): Result<CompanyVerifactuProfile, Error> {
const validationResult = CompanyVerifactuProfile.validateProps(props);
if (validationResult.isFailure) {
return Result.fail(validationResult.error);
}
return Result.ok(new CompanyVerifactuProfile(props, id));
}
public static rehydrate(
props: CompanyVerifactuProfileInternalProps,
id: UniqueID
): CompanyVerifactuProfile {
return new CompanyVerifactuProfile(props, id);
}
public reconfigure(
props: CompanyVerifactuProfileReconfiguration,
updatedAt: Date
): Result<void, Error> {
const candidate: CompanyVerifactuProfileProps = {
...this.props,
...props,
updatedAt,
};
const validationResult = CompanyVerifactuProfile.validateProps(candidate);
if (validationResult.isFailure) {
return Result.fail(validationResult.error);
}
this.props.profileStatus = candidate.profileStatus;
this.props.exemptionReasonCode = candidate.exemptionReasonCode;
this.props.exemptionReasonDetail = candidate.exemptionReasonDetail;
this.props.verifactuEffectiveAt = candidate.verifactuEffectiveAt;
this.props.autoRenewMode = candidate.autoRenewMode;
this.props.defaults = candidate.defaults;
this.props.updatedAt = updatedAt;
return Result.ok();
}
private static validateProps(props: CompanyVerifactuProfileProps): Result<void, Error> {
const defaultValues = [
props.defaults.taxRegimeCode,
props.defaults.operationQualificationCode,
props.defaults.exemptionReasonCode,
];
switch (props.profileStatus) {
case CompanyVerifactuProfileStatus.NotConfigured:
if (props.exemptionReasonCode.isSome()) {
return Result.fail(
new DomainValidationError(
"VERIFACTU_PROFILE_INVALID_STATE",
"exemptionReasonCode",
"Not configured profiles cannot define exemption reason codes."
)
);
}
if (props.exemptionReasonDetail.isSome()) {
return Result.fail(
new DomainValidationError(
"VERIFACTU_PROFILE_INVALID_STATE",
"exemptionReasonDetail",
"Not configured profiles cannot define exemption reason details."
)
);
}
if (props.verifactuEffectiveAt.isSome()) {
return Result.fail(
new DomainValidationError(
"VERIFACTU_PROFILE_INVALID_STATE",
"verifactuEffectiveAt",
"Not configured profiles cannot define a VeriFactu effective date."
)
);
}
if (props.autoRenewMode.isSome()) {
return Result.fail(
new DomainValidationError(
"VERIFACTU_PROFILE_INVALID_STATE",
"autoRenewMode",
"Not configured profiles cannot define an auto renew mode."
)
);
}
if (defaultValues.some((value) => value.isSome())) {
return Result.fail(
new DomainValidationError(
"VERIFACTU_PROFILE_INVALID_STATE",
"defaults",
"Not configured profiles cannot define effective fiscal defaults."
)
);
}
break;
case CompanyVerifactuProfileStatus.Exempt:
if (props.verifactuEffectiveAt.isSome()) {
return Result.fail(
new DomainValidationError(
"VERIFACTU_PROFILE_INVALID_STATE",
"verifactuEffectiveAt",
"Exempt profiles cannot activate VeriFactu."
)
);
}
if (props.autoRenewMode.isSome()) {
return Result.fail(
new DomainValidationError(
"VERIFACTU_PROFILE_INVALID_STATE",
"autoRenewMode",
"Exempt profiles cannot define auto renew mode."
)
);
}
break;
case CompanyVerifactuProfileStatus.ObligatedWithoutVerifactu:
if (props.verifactuEffectiveAt.isSome()) {
return Result.fail(
new DomainValidationError(
"VERIFACTU_PROFILE_INVALID_STATE",
"verifactuEffectiveAt",
"Profiles without VeriFactu cannot define a VeriFactu effective date."
)
);
}
if (props.autoRenewMode.isSome()) {
return Result.fail(
new DomainValidationError(
"VERIFACTU_PROFILE_INVALID_STATE",
"autoRenewMode",
"Profiles without VeriFactu cannot define auto renew mode."
)
);
}
break;
case CompanyVerifactuProfileStatus.ObligatedWithVerifactu:
if (props.verifactuEffectiveAt.isNone()) {
return Result.fail(
new DomainValidationError(
"VERIFACTU_PROFILE_INVALID_STATE",
"verifactuEffectiveAt",
"Profiles using VeriFactu must define an effective date."
)
);
}
break;
default:
return Result.fail(
new DomainValidationError(
"VERIFACTU_PROFILE_INVALID_STATUS",
"profileStatus",
`Unsupported VeriFactu profile status: ${String(props.profileStatus)}`
)
);
}
return Result.ok();
}
public get companyId(): UniqueID {
return this.props.companyId;
}
public get profileStatus(): CompanyVerifactuProfileStatusType {
return this.props.profileStatus;
}
public get exemptionReasonCode(): Maybe<string> {
return this.props.exemptionReasonCode;
}
public get exemptionReasonDetail(): Maybe<string> {
return this.props.exemptionReasonDetail;
}
public get verifactuEffectiveAt(): Maybe<Date> {
return this.props.verifactuEffectiveAt;
}
public get autoRenewMode(): Maybe<AutoRenewMode> {
return this.props.autoRenewMode;
}
public get defaults(): CompanyVerifactuDefaults {
return this.props.defaults;
}
public get createdAt(): Date {
return this.props.createdAt;
}
public get updatedAt(): Date {
return this.props.updatedAt;
}
public isConfigured(): boolean {
return this.profileStatus !== CompanyVerifactuProfileStatus.NotConfigured;
}
public usesVerifactu(): boolean {
return this.profileStatus === CompanyVerifactuProfileStatus.ObligatedWithVerifactu;
}
public static buildEmptyDefaults(): CompanyVerifactuDefaults {
return {
taxRegimeCode: Maybe.none(),
operationQualificationCode: Maybe.none(),
exemptionReasonCode: Maybe.none(),
};
}
}

View File

@ -0,0 +1,33 @@
import type { Maybe } from "@repo/rdx-utils";
export const CompanyVerifactuProfileStatus = {
NotConfigured: "not_configured",
Exempt: "exempt",
ObligatedWithoutVerifactu: "obligated_without_verifactu",
ObligatedWithVerifactu: "obligated_with_verifactu",
} as const;
export type CompanyVerifactuProfileStatus =
(typeof CompanyVerifactuProfileStatus)[keyof typeof CompanyVerifactuProfileStatus];
export const AutoRenewMode = {
Disabled: "disabled",
Enabled: "enabled",
} as const;
export type AutoRenewMode = (typeof AutoRenewMode)[keyof typeof AutoRenewMode];
export interface CompanyVerifactuDefaults {
taxRegimeCode: Maybe<string>;
operationQualificationCode: Maybe<string>;
exemptionReasonCode: Maybe<string>;
}
export interface CompanyVerifactuProfileReconfiguration {
profileStatus: CompanyVerifactuProfileStatus;
exemptionReasonCode: Maybe<string>;
exemptionReasonDetail: Maybe<string>;
verifactuEffectiveAt: Maybe<Date>;
autoRenewMode: Maybe<AutoRenewMode>;
defaults: CompanyVerifactuDefaults;
}

View File

@ -0,0 +1,2 @@
export * from "./company-verifactu-profile.aggregate";
export * from "./company-verifactu-profile.types";

View File

@ -1,4 +1,5 @@
export * from "./common"; export * from "./common";
export * from "./company-verifactu-profile";
export * from "./invoice-series"; export * from "./invoice-series";
export * from "./issued-invoices"; export * from "./issued-invoices";
export * from "./proformas"; export * from "./proformas";

View File

@ -27,7 +27,11 @@ import {
type VerifactuRecord, type VerifactuRecord,
} from "../entities"; } from "../entities";
import { IssuedInvoiceItemMismatch } from "../errors"; import { IssuedInvoiceItemMismatch } from "../errors";
import type { IssuedInvoiceRecipient } from "../value-objects"; import type {
IssuedInvoiceFiscalClassification,
IssuedInvoiceRecipient,
IssuedInvoiceVerifactuPolicy,
} from "../value-objects";
export interface IIssuedInvoiceCreateProps { export interface IIssuedInvoiceCreateProps {
companyId: UniqueID; companyId: UniqueID;
@ -75,6 +79,9 @@ export interface IIssuedInvoiceCreateProps {
taxesAmount: InvoiceAmount; taxesAmount: InvoiceAmount;
totalAmount: InvoiceAmount; totalAmount: InvoiceAmount;
fiscalClassification: Maybe<IssuedInvoiceFiscalClassification>;
verifactuPolicy: Maybe<IssuedInvoiceVerifactuPolicy>;
verifactu: Maybe<VerifactuRecord>; verifactu: Maybe<VerifactuRecord>;
} }
@ -163,6 +170,26 @@ export class IssuedInvoice
); );
} }
if (props.fiscalClassification.isNone()) {
return Result.fail(
new DomainValidationError(
"MISSING_FISCAL_CLASSIFICATION",
"fiscalClassification",
"Issued invoice requires a fiscal classification snapshot"
)
);
}
if (props.verifactuPolicy.isNone()) {
return Result.fail(
new DomainValidationError(
"MISSING_VERIFACTU_POLICY",
"verifactuPolicy",
"Issued invoice requires a VeriFactu policy snapshot"
)
);
}
return Result.ok(); return Result.ok();
} }
@ -270,6 +297,14 @@ export class IssuedInvoice
return this.props.verifactu; return this.props.verifactu;
} }
public get fiscalClassification(): Maybe<IssuedInvoiceFiscalClassification> {
return this.props.fiscalClassification;
}
public get verifactuPolicy(): Maybe<IssuedInvoiceVerifactuPolicy> {
return this.props.verifactuPolicy;
}
public get subtotalAmount(): InvoiceAmount { public get subtotalAmount(): InvoiceAmount {
return this.props.subtotalAmount; return this.props.subtotalAmount;
} }

View File

@ -1,3 +1,5 @@
export * from "./issued-invoice-fiscal-classification.vo";
export * from "./issued-invoice-recipient.vo"; export * from "./issued-invoice-recipient.vo";
export * from "./issued-invoice-tax-group.vo"; export * from "./issued-invoice-tax-group.vo";
export * from "./issued-invoice-verifactu-policy.vo";
export * from "./verifactu-status.vo"; export * from "./verifactu-status.vo";

View File

@ -0,0 +1,73 @@
import { ValueObject, maybeToNullable } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
export type IssuedInvoiceFiscalClassificationProps = {
taxRegimeCode: string;
taxRegimeLabel: Maybe<string>;
operationQualificationCode: Maybe<string>;
exemptionReasonCode: Maybe<string>;
};
export class IssuedInvoiceFiscalClassification extends ValueObject<IssuedInvoiceFiscalClassificationProps> {
protected static validate(values: IssuedInvoiceFiscalClassificationProps) {
if (!values.taxRegimeCode.trim()) {
return Result.fail(new Error("Issued invoice fiscal classification requires tax regime code"));
}
return Result.ok(values);
}
public static create(
values: IssuedInvoiceFiscalClassificationProps
): Result<IssuedInvoiceFiscalClassification, Error> {
const validationResult = IssuedInvoiceFiscalClassification.validate(values);
if (validationResult.isFailure) {
return Result.fail(validationResult.error);
}
return Result.ok(
new IssuedInvoiceFiscalClassification({
taxRegimeCode: values.taxRegimeCode.trim(),
taxRegimeLabel: values.taxRegimeLabel,
operationQualificationCode: values.operationQualificationCode,
exemptionReasonCode: values.exemptionReasonCode,
})
);
}
public get taxRegimeCode(): string {
return this.props.taxRegimeCode;
}
public get taxRegimeLabel(): Maybe<string> {
return this.props.taxRegimeLabel;
}
public get operationQualificationCode(): Maybe<string> {
return this.props.operationQualificationCode;
}
public get exemptionReasonCode(): Maybe<string> {
return this.props.exemptionReasonCode;
}
public getProps(): IssuedInvoiceFiscalClassificationProps {
return this.props;
}
public toPrimitive(): IssuedInvoiceFiscalClassificationProps {
return this.getProps();
}
public toObjectString() {
return {
tax_regime_code: this.taxRegimeCode,
tax_regime_label: maybeToNullable(this.taxRegimeLabel, (value) => value),
operation_qualification_code: maybeToNullable(this.operationQualificationCode, (value) => value),
operation_qualification_label: null,
exemption_reason_code: maybeToNullable(this.exemptionReasonCode, (value) => value),
exemption_reason_label: null,
};
}
}

View File

@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import test from "node:test";
import { UtcDate } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { IssuedInvoiceVerifactuPolicy } from "./issued-invoice-verifactu-policy.vo";
test("IssuedInvoiceVerifactuPolicy exige effectiveAt cuando usesVerifactu es true", () => {
const result = IssuedInvoiceVerifactuPolicy.create({
profileStatus: "obligated_with_verifactu",
usesVerifactu: true,
effectiveAt: Maybe.none(),
});
assert.equal(result.isFailure, true);
assert.match(result.error.message, /requires effectiveAt/i);
});
test("IssuedInvoiceVerifactuPolicy rechaza effectiveAt cuando profileStatus no usa VeriFactu", async () => {
const effectiveAt = UtcDate.createFromISO("2026-08-20T08:30:00.000Z");
if (effectiveAt.isFailure) {
throw effectiveAt.error;
}
const result = IssuedInvoiceVerifactuPolicy.create({
profileStatus: "not_configured",
usesVerifactu: false,
effectiveAt: Maybe.some(effectiveAt.data),
});
assert.equal(result.isFailure, true);
assert.match(result.error.message, /cannot define effectiveAt/i);
});
test("IssuedInvoiceVerifactuPolicy serializa la politica vigente", async () => {
const effectiveAt = UtcDate.createFromISO("2026-08-20T08:30:00.000Z");
if (effectiveAt.isFailure) {
throw effectiveAt.error;
}
const result = IssuedInvoiceVerifactuPolicy.create({
profileStatus: "obligated_with_verifactu",
usesVerifactu: true,
effectiveAt: Maybe.some(effectiveAt.data),
});
if (result.isFailure) {
throw result.error;
}
assert.deepEqual(result.data.toObjectString(), {
profile_status: "obligated_with_verifactu",
uses_verifactu: true,
effective_at: "2026-08-20T08:30:00.000Z",
});
});

View File

@ -0,0 +1,72 @@
import { type UtcDate, ValueObject, maybeToNullable } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import type { CompanyVerifactuProfileStatus } from "../../company-verifactu-profile";
import { CompanyVerifactuProfileStatus as CompanyVerifactuProfileStatusValues } from "../../company-verifactu-profile";
export type IssuedInvoiceVerifactuPolicyProps = {
profileStatus: CompanyVerifactuProfileStatus;
usesVerifactu: boolean;
effectiveAt: Maybe<UtcDate>;
};
export class IssuedInvoiceVerifactuPolicy extends ValueObject<IssuedInvoiceVerifactuPolicyProps> {
protected static validate(values: IssuedInvoiceVerifactuPolicyProps) {
const expectedUsesVerifactu =
values.profileStatus === CompanyVerifactuProfileStatusValues.ObligatedWithVerifactu;
if (values.usesVerifactu !== expectedUsesVerifactu) {
return Result.fail(new Error("Issued invoice VeriFactu policy usesVerifactu does not match profile status"));
}
if (values.usesVerifactu && values.effectiveAt.isNone()) {
return Result.fail(new Error("Issued invoice VeriFactu policy requires effectiveAt when VeriFactu is used"));
}
if (!values.usesVerifactu && values.effectiveAt.isSome()) {
return Result.fail(new Error("Issued invoice VeriFactu policy cannot define effectiveAt when VeriFactu is not used"));
}
return Result.ok(values);
}
public static create(
values: IssuedInvoiceVerifactuPolicyProps
): Result<IssuedInvoiceVerifactuPolicy, Error> {
const validationResult = IssuedInvoiceVerifactuPolicy.validate(values);
if (validationResult.isFailure) {
return Result.fail(validationResult.error);
}
return Result.ok(new IssuedInvoiceVerifactuPolicy(values));
}
public get profileStatus(): CompanyVerifactuProfileStatus {
return this.props.profileStatus;
}
public get usesVerifactu(): boolean {
return this.props.usesVerifactu;
}
public get effectiveAt(): Maybe<UtcDate> {
return this.props.effectiveAt;
}
public getProps(): IssuedInvoiceVerifactuPolicyProps {
return this.props;
}
public toPrimitive(): IssuedInvoiceVerifactuPolicyProps {
return this.getProps();
}
public toObjectString() {
return {
profile_status: this.profileStatus,
uses_verifactu: this.usesVerifactu,
effective_at: maybeToNullable(this.effectiveAt, (value) => value.toISOString()),
};
}
}

View File

@ -1,6 +1,8 @@
import type { IModuleServer } from "@erp/core/api"; import type { IModuleServer } from "@erp/core/api";
import { import {
buildCompanyVerifactuProfileDependencies,
companyVerifactuProfileRouter,
buildIssuedInvoicePublicServices, buildIssuedInvoicePublicServices,
buildIssuedInvoicesDependencies, buildIssuedInvoicesDependencies,
buildProformaPublicServices, buildProformaPublicServices,
@ -28,6 +30,7 @@ export const customerInvoicesAPIModule: IModuleServer = {
const { logger } = params; const { logger } = params;
// 1) Dominio interno // 1) Dominio interno
const companyVerifactuProfileInternal = buildCompanyVerifactuProfileDependencies(params);
const issuedInvoicesInternal = buildIssuedInvoicesDependencies(params); const issuedInvoicesInternal = buildIssuedInvoicesDependencies(params);
const proformasInternal = buildProformasDependencies(params); const proformasInternal = buildProformasDependencies(params);
@ -49,6 +52,7 @@ export const customerInvoicesAPIModule: IModuleServer = {
// Implementación privada del módulo // Implementación privada del módulo
internal: { internal: {
companyVerifactuProfile: companyVerifactuProfileInternal,
issuedInvoices: issuedInvoicesInternal, issuedInvoices: issuedInvoicesInternal,
proformas: proformasInternal, proformas: proformasInternal,
}, },
@ -66,6 +70,7 @@ export const customerInvoicesAPIModule: IModuleServer = {
const { logger } = params; const { logger } = params;
// Registro de rutas HTTP // Registro de rutas HTTP
companyVerifactuProfileRouter(params);
issuedInvoicesRouter(params); issuedInvoicesRouter(params);
proformasRouter(params); proformasRouter(params);

View File

@ -1,3 +1,4 @@
import companyVerifactuProfileModelInit from "./models/company-verifactu-profile.model";
import issuedInvoiceModelInit from "./models/sequelize-issued-invoice.model"; import issuedInvoiceModelInit from "./models/sequelize-issued-invoice.model";
import issuedInvoiceItemModelInit from "./models/sequelize-issued-invoice-item.model"; import issuedInvoiceItemModelInit from "./models/sequelize-issued-invoice-item.model";
import issuedInvoiceTaxModelInit from "./models/sequelize-issued-invoice-tax.model"; import issuedInvoiceTaxModelInit from "./models/sequelize-issued-invoice-tax.model";
@ -10,6 +11,7 @@ export * from "./models";
// Array de inicializadores para que registerModels() lo use. // Array de inicializadores para que registerModels() lo use.
export const models = [ export const models = [
companyVerifactuProfileModelInit,
proformaModelInit, proformaModelInit,
proformaItemModelInit, proformaItemModelInit,
proformaTaxModelInit, proformaTaxModelInit,

View File

@ -0,0 +1,121 @@
import {
DataTypes,
type InferAttributes,
type InferCreationAttributes,
Model,
type Sequelize,
} from "sequelize";
export type CompanyVerifactuProfileCreationAttributes = InferCreationAttributes<
CompanyVerifactuProfileModel
>;
export class CompanyVerifactuProfileModel extends Model<
InferAttributes<CompanyVerifactuProfileModel>,
InferCreationAttributes<CompanyVerifactuProfileModel>
> {
declare id: string;
declare company_id: string;
declare profile_status: string;
declare exemption_reason_code: string | null;
declare exemption_reason_detail: string | null;
declare verifactu_effective_at: Date | null;
declare auto_renew_mode: string | null;
declare default_tax_regime_code: string | null;
declare default_operation_qualification_code: string | null;
declare default_exemption_reason_code: string | null;
declare created_at: Date;
declare updated_at: Date;
static associate(_database: Sequelize) {}
static hooks(_database: Sequelize) {}
}
export default (database: Sequelize) => {
CompanyVerifactuProfileModel.init(
{
id: {
type: DataTypes.UUID,
primaryKey: true,
charset: "utf8mb4",
collate: "utf8mb4_bin",
} as never,
company_id: {
type: DataTypes.UUID,
allowNull: false,
},
profile_status: {
type: DataTypes.STRING(64),
allowNull: false,
},
exemption_reason_code: {
type: DataTypes.STRING(64),
allowNull: true,
defaultValue: null,
},
exemption_reason_detail: {
type: DataTypes.STRING(1024),
allowNull: true,
defaultValue: null,
},
verifactu_effective_at: {
type: DataTypes.DATE,
allowNull: true,
defaultValue: null,
},
auto_renew_mode: {
type: DataTypes.STRING(32),
allowNull: true,
defaultValue: null,
},
default_tax_regime_code: {
type: DataTypes.STRING(64),
allowNull: true,
defaultValue: null,
},
default_operation_qualification_code: {
type: DataTypes.STRING(64),
allowNull: true,
defaultValue: null,
},
default_exemption_reason_code: {
type: DataTypes.STRING(64),
allowNull: true,
defaultValue: null,
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
},
},
{
sequelize: database,
modelName: "CompanyVerifactuProfileModel",
tableName: "company_verifactu_profiles",
charset: "utf8mb4",
collate: "utf8mb4_unicode_ci",
underscored: true,
timestamps: false,
indexes: [
{
name: "uq_company_verifactu_profiles_company_id",
fields: ["company_id"],
unique: true,
},
{
name: "idx_company_verifactu_profiles_status",
fields: ["profile_status"],
},
],
defaultScope: {},
scopes: {},
}
);
return CompanyVerifactuProfileModel;
};

View File

@ -1,3 +1,4 @@
export * from "./company-verifactu-profile.model";
export * from "./sequelize-proforma.model"; export * from "./sequelize-proforma.model";
export * from "./sequelize-proforma-item.model"; export * from "./sequelize-proforma-item.model";
export * from "./sequelize-proforma-tax.model"; export * from "./sequelize-proforma-tax.model";

View File

@ -73,6 +73,11 @@ export class IssuedInvoiceModel extends Model<
// Régimen fiscal // Régimen fiscal
declare tax_regime_code: string; declare tax_regime_code: string;
declare tax_regime_description: string; declare tax_regime_description: string;
declare operation_qualification_code: CreationOptional<string | null>;
declare exemption_reason_code: CreationOptional<string | null>;
declare verifactu_profile_status: CreationOptional<string | null>;
declare uses_verifactu: CreationOptional<boolean | null>;
declare verifactu_effective_at: CreationOptional<Date | null>;
// Subtotal // Subtotal
declare subtotal_amount_value: number; declare subtotal_amount_value: number;
@ -336,6 +341,31 @@ export default (database: Sequelize) => {
type: new DataTypes.STRING(), type: new DataTypes.STRING(),
allowNull: false, allowNull: false,
}, },
operation_qualification_code: {
type: DataTypes.STRING(64),
allowNull: true,
defaultValue: null,
},
exemption_reason_code: {
type: DataTypes.STRING(64),
allowNull: true,
defaultValue: null,
},
verifactu_profile_status: {
type: DataTypes.STRING(64),
allowNull: true,
defaultValue: null,
},
uses_verifactu: {
type: DataTypes.BOOLEAN,
allowNull: true,
defaultValue: null,
},
verifactu_effective_at: {
type: DataTypes.DATE,
allowNull: true,
defaultValue: null,
},
subtotal_amount_value: { subtotal_amount_value: {
type: new DataTypes.BIGINT(), type: new DataTypes.BIGINT(),
allowNull: false, allowNull: false,

View File

@ -0,0 +1,49 @@
import { type ModuleParams, buildTransactionManager } from "@erp/core/api";
import {
CompanyVerifactuProfileResponseBuilder,
type GetCompanyVerifactuProfileUseCase,
type UpdateCompanyVerifactuProfileUseCase,
buildGetCompanyVerifactuProfileUseCase,
buildUpdateCompanyVerifactuProfileUseCase,
} from "../../../application";
import {
CompanyVerifactuProfileDomainMapper,
SequelizeCompanyVerifactuProfileRepository,
} from "../persistence";
export type CompanyVerifactuProfileInternalDeps = {
useCases: {
getCompanyVerifactuProfile: () => GetCompanyVerifactuProfileUseCase;
updateCompanyVerifactuProfile: () => UpdateCompanyVerifactuProfileUseCase;
};
};
export function buildCompanyVerifactuProfileDependencies(
params: ModuleParams
): CompanyVerifactuProfileInternalDeps {
const transactionManager = buildTransactionManager(params.database);
const repository = new SequelizeCompanyVerifactuProfileRepository(
new CompanyVerifactuProfileDomainMapper(),
params.database
);
const responseBuilder = new CompanyVerifactuProfileResponseBuilder();
return {
useCases: {
getCompanyVerifactuProfile: () =>
buildGetCompanyVerifactuProfileUseCase({
repository,
responseBuilder,
transactionManager,
}),
updateCompanyVerifactuProfile: () =>
buildUpdateCompanyVerifactuProfileUseCase({
repository,
responseBuilder,
transactionManager,
now: () => new Date(),
}),
},
};
}

View File

@ -0,0 +1 @@
export * from "./company-verifactu-profile.di";

View File

@ -0,0 +1,42 @@
import { type StartParams, validateRequest } from "@erp/core/api";
import { requireIdentityTenant } from "@erp/identity/api";
import { type NextFunction, type Request, type Response, Router } from "express";
import { UpdateCompanyVerifactuProfileRequestSchema } from "../../../../common";
import type { CompanyVerifactuProfileInternalDeps } from "../di";
import {
GetCompanyVerifactuProfileController,
UpdateCompanyVerifactuProfileController,
} from "./controllers";
export const companyVerifactuProfileRouter = (params: StartParams) => {
const { app, config, getInternal } = params;
const deps = getInternal<CompanyVerifactuProfileInternalDeps>(
"customer-invoices",
"companyVerifactuProfile"
);
const router = Router({ mergeParams: true });
router.use(...requireIdentityTenant(params));
router.get("/", (req: Request, res: Response, next: NextFunction) => {
const controller = new GetCompanyVerifactuProfileController(
deps.useCases.getCompanyVerifactuProfile()
);
return controller.execute(req, res, next);
});
router.put(
"/",
validateRequest(UpdateCompanyVerifactuProfileRequestSchema, "body"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new UpdateCompanyVerifactuProfileController(
deps.useCases.updateCompanyVerifactuProfile()
);
return controller.execute(req, res, next);
}
);
app.use(`${config.server.apiBasePath}/customer-invoices/settings/verifactu`, router);
};

View File

@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import test from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import type { NextFunction, Request, Response } from "express";
import {
GetCompanyVerifactuProfileController,
UpdateCompanyVerifactuProfileController,
} from "./index.ts";
function createResponseDouble() {
return {
statusCode: 200,
headers: {} as Record<string, string>,
body: undefined as unknown,
type() {
return this;
},
status(code: number) {
this.statusCode = code;
return this;
},
json(payload: unknown) {
this.body = payload;
return this;
},
sendStatus(code: number) {
this.statusCode = code;
return this;
},
setHeader(key: string, value: string) {
this.headers[key] = value;
return this;
},
set(values: Record<string, string>) {
Object.assign(this.headers, values);
return this;
},
send(payload?: unknown) {
this.body = payload;
return this;
},
};
}
function createRequestDouble(overrides: Partial<Request> = {}): Request {
return {
method: "GET",
protocol: "http",
originalUrl: "/api/customer-invoices/settings/verifactu",
query: {},
params: {},
body: {},
get: () => undefined,
...overrides,
} as Request;
}
test("GET controller devuelve 401 sin autenticación", async () => {
const controller = new GetCompanyVerifactuProfileController({
execute: async () => {
throw new Error("should not run");
},
} as never);
const res = createResponseDouble();
await controller.execute(createRequestDouble(), res as never as Response, (() => undefined) as NextFunction);
assert.equal(res.statusCode, 401);
});
test("PUT controller devuelve 403 si falta company context", async () => {
const controller = new UpdateCompanyVerifactuProfileController({
execute: async () => {
throw new Error("should not run");
},
} as never);
const res = createResponseDouble();
const req = createRequestDouble({
method: "PUT",
body: {
profile_status: "not_configured",
defaults: {},
},
user: {
userId: UniqueID.generateNewID(),
},
} as Partial<Request>);
await controller.execute(req, res as never as Response, (() => undefined) as NextFunction);
assert.equal(res.statusCode, 403);
});

View File

@ -0,0 +1,34 @@
import {
ExpressController,
forbidQueryFieldGuard,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import type { GetCompanyVerifactuProfileUseCase } from "../../../../application";
export class GetCompanyVerifactuProfileController extends ExpressController {
public constructor(private readonly useCase: GetCompanyVerifactuProfileUseCase) {
super();
this.registerGuards(
requireAuthenticatedGuard(),
requireCompanyContextGuard(),
forbidQueryFieldGuard("companyId"),
forbidQueryFieldGuard("company_id")
);
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) {
return this.forbiddenError("Tenant ID not found");
}
const result = await this.useCase.execute({ companyId });
return result.match(
(data) => this.ok(data),
(error) => this.handleError(error)
);
}
}

View File

@ -0,0 +1,2 @@
export * from "./get-company-verifactu-profile.controller";
export * from "./update-company-verifactu-profile.controller";

View File

@ -0,0 +1,40 @@
import {
ExpressController,
forbidQueryFieldGuard,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import type { UpdateCompanyVerifactuProfileRequestDTO } from "../../../../../common";
import type { UpdateCompanyVerifactuProfileUseCase } from "../../../../application";
export class UpdateCompanyVerifactuProfileController extends ExpressController {
public constructor(private readonly useCase: UpdateCompanyVerifactuProfileUseCase) {
super();
this.registerGuards(
requireAuthenticatedGuard(),
requireCompanyContextGuard(),
forbidQueryFieldGuard("companyId"),
forbidQueryFieldGuard("company_id")
);
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) {
return this.forbiddenError("Tenant ID not found");
}
const dto = this.req.body satisfies UpdateCompanyVerifactuProfileRequestDTO;
const result = await this.useCase.execute({
companyId,
dto,
});
return result.match(
(data) => this.ok(data),
(error) => this.handleError(error)
);
}
}

View File

@ -0,0 +1,2 @@
export * from "./company-verifactu-profile.routes";
export * from "./controllers";

View File

@ -0,0 +1,3 @@
export * from "./di";
export * from "./express";
export * from "./persistence";

View File

@ -0,0 +1 @@
export * from "./sequelize";

View File

@ -0,0 +1,2 @@
export * from "./mappers";
export * from "./repositories";

View File

@ -0,0 +1,105 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import {
type AutoRenewMode,
CompanyVerifactuProfile,
type CompanyVerifactuProfileProps,
type CompanyVerifactuProfileStatus,
} from "../../../../../domain";
import type {
CompanyVerifactuProfileCreationAttributes,
CompanyVerifactuProfileModel,
} from "../../../../common";
export class CompanyVerifactuProfileDomainMapper {
public mapToDomain(source: CompanyVerifactuProfileModel): Result<CompanyVerifactuProfile, Error> {
const idResult = UniqueID.create(source.id);
if (idResult.isFailure) {
return Result.fail(idResult.error);
}
const companyIdResult = UniqueID.create(source.company_id);
if (companyIdResult.isFailure) {
return Result.fail(companyIdResult.error);
}
const props: CompanyVerifactuProfileProps = {
companyId: companyIdResult.data,
profileStatus: source.profile_status as CompanyVerifactuProfileStatus,
exemptionReasonCode:
source.exemption_reason_code == null
? Maybe.none<string>()
: Maybe.some(source.exemption_reason_code),
exemptionReasonDetail:
source.exemption_reason_detail == null
? Maybe.none<string>()
: Maybe.some(source.exemption_reason_detail),
verifactuEffectiveAt:
source.verifactu_effective_at == null
? Maybe.none<Date>()
: Maybe.some(source.verifactu_effective_at),
autoRenewMode:
source.auto_renew_mode == null
? Maybe.none<AutoRenewMode>()
: Maybe.some(source.auto_renew_mode as AutoRenewMode),
defaults: {
taxRegimeCode:
source.default_tax_regime_code == null
? Maybe.none<string>()
: Maybe.some(source.default_tax_regime_code),
operationQualificationCode:
source.default_operation_qualification_code == null
? Maybe.none<string>()
: Maybe.some(source.default_operation_qualification_code),
exemptionReasonCode:
source.default_exemption_reason_code == null
? Maybe.none<string>()
: Maybe.some(source.default_exemption_reason_code),
},
createdAt: source.created_at,
updatedAt: source.updated_at,
};
return Result.ok(CompanyVerifactuProfile.rehydrate(props, idResult.data));
}
public mapToPersistence(
source: CompanyVerifactuProfile
): Result<CompanyVerifactuProfileCreationAttributes, Error> {
const exemptionReasonCode = source.exemptionReasonCode.isSome()
? source.exemptionReasonCode.unwrap()
: null;
const exemptionReasonDetail = source.exemptionReasonDetail.isSome()
? source.exemptionReasonDetail.unwrap()
: null;
const verifactuEffectiveAt = source.verifactuEffectiveAt.isSome()
? source.verifactuEffectiveAt.unwrap()
: null;
const autoRenewMode = source.autoRenewMode.isSome() ? source.autoRenewMode.unwrap() : null;
const taxRegimeCode = source.defaults.taxRegimeCode.isSome()
? source.defaults.taxRegimeCode.unwrap()
: null;
const operationQualificationCode = source.defaults.operationQualificationCode.isSome()
? source.defaults.operationQualificationCode.unwrap()
: null;
const defaultExemptionReasonCode = source.defaults.exemptionReasonCode.isSome()
? source.defaults.exemptionReasonCode.unwrap()
: null;
return Result.ok({
id: source.id.toPrimitive(),
company_id: source.companyId.toPrimitive(),
profile_status: source.profileStatus,
exemption_reason_code: exemptionReasonCode,
exemption_reason_detail: exemptionReasonDetail,
verifactu_effective_at: verifactuEffectiveAt,
auto_renew_mode: autoRenewMode,
default_tax_regime_code: taxRegimeCode,
default_operation_qualification_code: operationQualificationCode,
default_exemption_reason_code: defaultExemptionReasonCode,
created_at: source.createdAt,
updated_at: source.updatedAt,
});
}
}

View File

@ -0,0 +1 @@
export * from "./company-verifactu-profile-domain.mapper";

View File

@ -0,0 +1,124 @@
import assert from "node:assert/strict";
import test from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { Sequelize } from "sequelize";
import {
CompanyVerifactuProfile,
CompanyVerifactuProfileStatus,
} from "../../../../../domain/index.ts";
import { CompanyVerifactuProfileModel } from "../../../../common/index.ts";
import { CompanyVerifactuProfileDomainMapper } from "../mappers/index.ts";
import { SequelizeCompanyVerifactuProfileRepository } from "./index.ts";
function buildRepository() {
const database = new Sequelize("mysql://root:pass@localhost:3306/erp_test", {
logging: false,
});
return new SequelizeCompanyVerifactuProfileRepository(
new CompanyVerifactuProfileDomainMapper(),
database
);
}
function buildProfile() {
const result = CompanyVerifactuProfile.create({
companyId: UniqueID.generateNewID(),
profileStatus: CompanyVerifactuProfileStatus.ObligatedWithVerifactu,
exemptionReasonCode: Maybe.none(),
exemptionReasonDetail: Maybe.none(),
verifactuEffectiveAt: Maybe.some(new Date("2026-08-11T12:00:00.000Z")),
autoRenewMode: Maybe.some("enabled"),
defaults: {
taxRegimeCode: Maybe.some("01"),
operationQualificationCode: Maybe.some("standard"),
exemptionReasonCode: Maybe.none(),
},
createdAt: new Date("2026-08-11T10:00:00.000Z"),
updatedAt: new Date("2026-08-11T10:00:00.000Z"),
});
assert.equal(result.isSuccess, true);
return result.data;
}
test("findByCompanyId devuelve none cuando no hay fila y preserva el filtro company_id", async () => {
const repository = buildRepository();
const originalFindOne = CompanyVerifactuProfileModel.findOne;
let receivedWhere: unknown;
CompanyVerifactuProfileModel.findOne = (async (options?: { where?: unknown }) => {
receivedWhere = options?.where;
return null;
}) as typeof CompanyVerifactuProfileModel.findOne;
try {
const companyId = UniqueID.generateNewID();
const result = await repository.findByCompanyId(companyId);
assert.equal(result.isSuccess, true);
assert.equal(result.data.isNone(), true);
assert.deepEqual(receivedWhere, { company_id: companyId.toPrimitive() });
} finally {
CompanyVerifactuProfileModel.findOne = originalFindOne;
}
});
test("create persiste una única configuración por company con payload esperado", async () => {
const repository = buildRepository();
const profile = buildProfile();
const originalCreate = CompanyVerifactuProfileModel.create;
let receivedPayload: unknown;
CompanyVerifactuProfileModel.create = (async (payload: unknown) => {
receivedPayload = payload;
return {} as never;
}) as typeof CompanyVerifactuProfileModel.create;
try {
const result = await repository.create(profile);
assert.equal(result.isSuccess, true);
assert.equal(
(receivedPayload as { company_id: string }).company_id,
profile.companyId.toPrimitive()
);
assert.equal(
(receivedPayload as { profile_status: string }).profile_status,
CompanyVerifactuProfileStatus.ObligatedWithVerifactu
);
} finally {
CompanyVerifactuProfileModel.create = originalCreate;
}
});
test("update mantiene aislamiento por id y company_id", async () => {
const repository = buildRepository();
const profile = buildProfile();
const originalUpdate = CompanyVerifactuProfileModel.update;
let receivedWhere: unknown;
CompanyVerifactuProfileModel.update = (async (
_payload: unknown,
options?: { where?: unknown }
) => {
receivedWhere = options?.where;
return [1] as [number];
}) as typeof CompanyVerifactuProfileModel.update;
try {
const result = await repository.update(profile);
assert.equal(result.isSuccess, true);
assert.deepEqual(receivedWhere, {
id: profile.id.toPrimitive(),
company_id: profile.companyId.toPrimitive(),
});
} finally {
CompanyVerifactuProfileModel.update = originalUpdate;
}
});

View File

@ -0,0 +1,109 @@
import {
InfrastructureRepositoryError,
SequelizeRepository,
translateSequelizeError,
} from "@erp/core/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import type { ICompanyVerifactuProfileRepository } from "../../../../../application";
import type { CompanyVerifactuProfile } from "../../../../../domain";
import { CompanyVerifactuProfileModel } from "../../../../common";
import type { CompanyVerifactuProfileDomainMapper } from "../mappers";
export class SequelizeCompanyVerifactuProfileRepository
extends SequelizeRepository<CompanyVerifactuProfile>
implements ICompanyVerifactuProfileRepository
{
public constructor(
private readonly mapper: CompanyVerifactuProfileDomainMapper,
database: Sequelize
) {
super({ database });
}
public async findByCompanyId(
companyId: UniqueID,
transaction?: unknown
): Promise<Result<Maybe<CompanyVerifactuProfile>, Error>> {
try {
const row = await CompanyVerifactuProfileModel.findOne({
where: {
company_id: companyId.toPrimitive(),
},
transaction: transaction as Transaction | undefined,
});
if (!row) {
return Result.ok(Maybe.none());
}
const profileResult = this.mapper.mapToDomain(row);
if (profileResult.isFailure) {
return Result.fail(profileResult.error);
}
return Result.ok(Maybe.some(profileResult.data));
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
public async create(
profile: CompanyVerifactuProfile,
transaction?: unknown
): Promise<Result<void, Error>> {
try {
const dtoResult = this.mapper.mapToPersistence(profile);
if (dtoResult.isFailure) {
return Result.fail(dtoResult.error);
}
await CompanyVerifactuProfileModel.create(dtoResult.data, {
transaction: transaction as Transaction | undefined,
});
return Result.ok();
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
public async update(
profile: CompanyVerifactuProfile,
transaction?: unknown
): Promise<Result<void, Error>> {
try {
const dtoResult = this.mapper.mapToPersistence(profile);
if (dtoResult.isFailure) {
return Result.fail(dtoResult.error);
}
const { id, company_id, created_at, ...updatePayload } = dtoResult.data;
const [affectedRows] = await CompanyVerifactuProfileModel.update(updatePayload, {
where: {
id,
company_id,
},
transaction: transaction as Transaction | undefined,
});
if (affectedRows === 0) {
return Result.fail(
new InfrastructureRepositoryError(
`Company VeriFactu profile ${id} was not found for company ${company_id}`
)
);
}
return Result.ok();
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
}

View File

@ -0,0 +1 @@
export * from "./company-verifactu-profile.repository";

View File

@ -1,3 +1,4 @@
export * from "./common"; export * from "./common";
export * from "./company-verifactu-profile";
export * from "./issued-invoices"; export * from "./issued-invoices";
export * from "./proformas"; export * from "./proformas";

View File

@ -12,9 +12,10 @@ import {
maybeFromNullableResult, maybeFromNullableResult,
maybeToNullable, maybeToNullable,
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { Result, isNullishOrEmpty } from "@repo/rdx-utils"; import { Maybe, Result, isNullishOrEmpty } from "@repo/rdx-utils";
import { import {
type CompanyVerifactuProfileStatus,
type InternalIssuedInvoiceProps, type InternalIssuedInvoiceProps,
InvoiceAmount, InvoiceAmount,
InvoiceNumber, InvoiceNumber,
@ -22,9 +23,11 @@ import {
InvoiceSerie, InvoiceSerie,
InvoiceStatus, InvoiceStatus,
InvoiceTaxRegime, InvoiceTaxRegime,
IssuedInvoiceFiscalClassification,
IssuedInvoice, IssuedInvoice,
IssuedInvoiceItems, IssuedInvoiceItems,
IssuedInvoiceTaxes, IssuedInvoiceTaxes,
IssuedInvoiceVerifactuPolicy,
} from "../../../../../../domain"; } from "../../../../../../domain";
import type { IssuedInvoiceCreationAttributes, IssuedInvoiceModel } from "../../../../../common"; import type { IssuedInvoiceCreationAttributes, IssuedInvoiceModel } from "../../../../../common";
@ -221,6 +224,7 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
"total_amount_value", "total_amount_value",
errors errors
); );
const hasVerifactuSnapshots = raw.verifactu_profile_status != null;
return { return {
invoiceId, invoiceId,
@ -252,6 +256,7 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
retentionAmount, retentionAmount,
taxesAmount, taxesAmount,
totalAmount, totalAmount,
hasVerifactuSnapshots,
}; };
} }
@ -302,6 +307,58 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
languageCode: attributes.languageCode!, languageCode: attributes.languageCode!,
currencyCode: attributes.currencyCode!, currencyCode: attributes.currencyCode!,
}); });
let fiscalClassification = Maybe.none<IssuedInvoiceFiscalClassification>();
let verifactuPolicy = Maybe.none<IssuedInvoiceVerifactuPolicy>();
if (attributes.hasVerifactuSnapshots) {
const fiscalClassificationResult = IssuedInvoiceFiscalClassification.create({
taxRegimeCode: attributes.taxRegime!.code,
taxRegimeLabel:
attributes.taxRegime!.description.trim().length > 0
? Maybe.some(attributes.taxRegime!.description)
: Maybe.none(),
operationQualificationCode:
raw.operation_qualification_code == null
? Maybe.none()
: Maybe.some(String(raw.operation_qualification_code)),
exemptionReasonCode:
raw.exemption_reason_code == null
? Maybe.none()
: Maybe.some(String(raw.exemption_reason_code)),
});
if (fiscalClassificationResult.isFailure) {
return Result.fail(fiscalClassificationResult.error);
}
fiscalClassification = Maybe.some(fiscalClassificationResult.data);
const effectiveAt = extractOrPushError(
maybeFromNullableResult(raw.verifactu_effective_at, (value) =>
UtcDate.createFromISO(value instanceof Date ? value.toISOString() : String(value))
),
"verifactu_effective_at",
errors
);
if (errors.length > 0) {
return Result.fail(
new ValidationErrorCollection("Issued invoice V2 mapping failed [mapToDomain]", errors)
);
}
const verifactuPolicyResult = IssuedInvoiceVerifactuPolicy.create({
profileStatus: String(raw.verifactu_profile_status) as CompanyVerifactuProfileStatus,
usesVerifactu: Boolean(raw.uses_verifactu),
effectiveAt: effectiveAt ?? Maybe.none<UtcDate>(),
});
if (verifactuPolicyResult.isFailure) {
return Result.fail(verifactuPolicyResult.error);
}
verifactuPolicy = Maybe.some(verifactuPolicyResult.data);
}
const invoiceProps: InternalIssuedInvoiceProps = { const invoiceProps: InternalIssuedInvoiceProps = {
companyId: attributes.companyId!, companyId: attributes.companyId!,
@ -332,6 +389,8 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
totalAmount: attributes.totalAmount!, totalAmount: attributes.totalAmount!,
paymentMethod: attributes.paymentMethod!, paymentMethod: attributes.paymentMethod!,
taxRegime: attributes.taxRegime!, taxRegime: attributes.taxRegime!,
fiscalClassification,
verifactuPolicy,
taxes, taxes,
verifactu: verifactuResult.data, verifactu: verifactuResult.data,
linkedProformaId: attributes.linkedProformaId!, linkedProformaId: attributes.linkedProformaId!,
@ -381,7 +440,7 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
); );
} }
return Result.ok({ const persistence: IssuedInvoiceCreationAttributes = {
id: source.id.toPrimitive(), id: source.id.toPrimitive(),
company_id: source.companyId.toPrimitive(), company_id: source.companyId.toPrimitive(),
status: source.status.toPrimitive(), status: source.status.toPrimitive(),
@ -406,6 +465,21 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
payment_term_snapshot_json: null, payment_term_snapshot_json: null,
tax_regime_code: source.taxRegime.code, tax_regime_code: source.taxRegime.code,
tax_regime_description: source.taxRegime.description, tax_regime_description: source.taxRegime.description,
operation_qualification_code: source.fiscalClassification.isSome()
? source.fiscalClassification.unwrap().operationQualificationCode.getOrUndefined() ?? null
: null,
exemption_reason_code: source.fiscalClassification.isSome()
? source.fiscalClassification.unwrap().exemptionReasonCode.getOrUndefined() ?? null
: null,
verifactu_profile_status: source.verifactuPolicy.isSome()
? source.verifactuPolicy.unwrap().profileStatus
: null,
uses_verifactu: source.verifactuPolicy.isSome()
? source.verifactuPolicy.unwrap().usesVerifactu
: null,
verifactu_effective_at: source.verifactuPolicy.isSome()
? source.verifactuPolicy.unwrap().effectiveAt.getOrUndefined()?.toDate() ?? null
: null,
subtotal_amount_value: source.subtotalAmount.value, subtotal_amount_value: source.subtotalAmount.value,
subtotal_amount_scale: source.subtotalAmount.scale, subtotal_amount_scale: source.subtotalAmount.scale,
items_discount_amount_value: source.itemsDiscountAmount.value, items_discount_amount_value: source.itemsDiscountAmount.value,
@ -433,6 +507,8 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
taxes: taxesResult.data, taxes: taxesResult.data,
items: itemsResult.data, items: itemsResult.data,
verifactu: verifactuResult.data, verifactu: verifactuResult.data,
} satisfies IssuedInvoiceCreationAttributes); };
return Result.ok(persistence);
} }
} }

View File

@ -43,6 +43,10 @@ import {
buildUnarchiveProformaByIdUseCase, buildUnarchiveProformaByIdUseCase,
buildUpdateProformaUseCase, buildUpdateProformaUseCase,
} from "../../../application"; } from "../../../application";
import {
CompanyVerifactuProfileDomainMapper,
SequelizeCompanyVerifactuProfileRepository,
} from "../../company-verifactu-profile/persistence";
import { CompanyReportProfileFinder } from "../adapters/company-report-profile-finder"; import { CompanyReportProfileFinder } from "../adapters/company-report-profile-finder";
import { DocumentSeriesProformaSeriesValidator } from "../services/document-series-proforma-series-validator"; import { DocumentSeriesProformaSeriesValidator } from "../services/document-series-proforma-series-validator";
import { DocumentSeriesProformaTargetInvoiceSeriesValidator } from "../services/document-series-proforma-target-invoice-series-validator"; import { DocumentSeriesProformaTargetInvoiceSeriesValidator } from "../services/document-series-proforma-target-invoice-series-validator";
@ -89,6 +93,10 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
documentSeriesServices, documentSeriesServices,
}); });
const { repository, numberGenerator: proformaNumberService } = persistence; const { repository, numberGenerator: proformaNumberService } = persistence;
const companyVerifactuProfileRepository = new SequelizeCompanyVerifactuProfileRepository(
new CompanyVerifactuProfileDomainMapper(),
database
);
const inputMappers = buildProformaInputMappers(); const inputMappers = buildProformaInputMappers();
const snapshotBuilders = buildProformaSnapshotBuilders(); const snapshotBuilders = buildProformaSnapshotBuilders();
@ -149,6 +157,8 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
const issueReadModelAssembler = buildProformaIssueReadModelAssembler({ const issueReadModelAssembler = buildProformaIssueReadModelAssembler({
paymentMethodFinder: catalogs.paymentMethod.finder, paymentMethodFinder: catalogs.paymentMethod.finder,
taxRegimeFinder: catalogs.taxRegime.finder, taxRegimeFinder: catalogs.taxRegime.finder,
customerServices,
companyVerifactuProfileRepository,
}); });
const companyReportProfileFinder = new CompanyReportProfileFinder({ const companyReportProfileFinder = new CompanyReportProfileFinder({

View File

@ -1,2 +1,3 @@
export * from "./issued-invoices"; export * from "./issued-invoices";
export * from "./proformas"; export * from "./proformas";
export * from "./settings";

View File

@ -0,0 +1 @@
export * from "./update-company-verifactu-profile.request.dto";

View File

@ -0,0 +1,29 @@
import { z } from "zod/v4";
export const CompanyVerifactuProfileStatusSchema = z.enum([
"not_configured",
"exempt",
"obligated_without_verifactu",
"obligated_with_verifactu",
]);
export const AutoRenewModeSchema = z.enum(["disabled", "enabled"]);
export const CompanyVerifactuDefaultsRequestSchema = z.object({
tax_regime_code: z.string().trim().min(1).max(64).nullable().optional(),
operation_qualification_code: z.string().trim().min(1).max(64).nullable().optional(),
exemption_reason_code: z.string().trim().min(1).max(64).nullable().optional(),
});
export const UpdateCompanyVerifactuProfileRequestSchema = z.object({
profile_status: CompanyVerifactuProfileStatusSchema,
exemption_reason_code: z.string().trim().min(1).max(64).nullable().optional(),
exemption_reason_detail: z.string().trim().max(1024).nullable().optional(),
verifactu_effective_at: z.iso.datetime().nullable().optional(),
auto_renew_mode: AutoRenewModeSchema.nullable().optional(),
defaults: CompanyVerifactuDefaultsRequestSchema,
});
export type UpdateCompanyVerifactuProfileRequestDTO = z.infer<
typeof UpdateCompanyVerifactuProfileRequestSchema
>;

View File

@ -1,2 +1,3 @@
export * from "./issued-invoices"; export * from "./issued-invoices";
export * from "./proformas"; export * from "./proformas";
export * from "./settings";

View File

@ -9,12 +9,14 @@ import {
import { z } from "zod/v4"; import { z } from "zod/v4";
import { import {
IssuedInvoiceFiscalClassificationSchema,
IssuedInvoiceItemDetailSchema, IssuedInvoiceItemDetailSchema,
IssuedInvoiceRecipientSummarySchema, IssuedInvoiceRecipientSummarySchema,
IssuedInvoiceStatusSchema, IssuedInvoiceStatusSchema,
PaymentMethodRefSchema, PaymentMethodRefSchema,
TaxRegimeRefSchema, TaxRegimeRefSchema,
TaxesBreakdownSchema, TaxesBreakdownSchema,
IssuedInvoiceVerifactuPolicySchema,
VerifactuRecordSchema, VerifactuRecordSchema,
} from "../../shared"; } from "../../shared";
@ -49,6 +51,8 @@ export const GetIssuedInvoiceByIdResponseSchema = z.object({
payment_method: PaymentMethodRefSchema.nullable(), payment_method: PaymentMethodRefSchema.nullable(),
tax_regime: TaxRegimeRefSchema, tax_regime: TaxRegimeRefSchema,
fiscal_classification: IssuedInvoiceFiscalClassificationSchema.nullable(),
verifactu_policy: IssuedInvoiceVerifactuPolicySchema.nullable(),
subtotal_amount: MoneySchema, subtotal_amount: MoneySchema,
@ -65,9 +69,8 @@ export const GetIssuedInvoiceByIdResponseSchema = z.object({
taxes_amount: MoneySchema, taxes_amount: MoneySchema,
total_amount: MoneySchema, total_amount: MoneySchema,
verifactu: VerifactuRecordSchema,
items: z.array(IssuedInvoiceItemDetailSchema), items: z.array(IssuedInvoiceItemDetailSchema),
verifactu: VerifactuRecordSchema,
metadata: MetadataSchema.optional(), metadata: MetadataSchema.optional(),
}); });

View File

@ -1,7 +1,12 @@
import { CurrencyCodeSchema, IsoDateSchema, MoneySchema } from "@erp/core"; import { CurrencyCodeSchema, IsoDateSchema, MoneySchema } from "@erp/core";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { IssuedInvoiceStatusSchema, TaxRegimeRefSchema } from "../../shared"; import {
IssuedInvoiceFiscalClassificationSchema,
IssuedInvoiceStatusSchema,
IssuedInvoiceVerifactuPolicySchema,
TaxRegimeRefSchema,
} from "../../shared";
export const IssueProformaIssuedInvoiceRecipientSchema = z.object({ export const IssueProformaIssuedInvoiceRecipientSchema = z.object({
name: z.string(), name: z.string(),
@ -28,6 +33,8 @@ export const IssueProformaIssuedInvoiceSchema = z.object({
status: IssuedInvoiceStatusSchema, status: IssuedInvoiceStatusSchema,
recipient: IssueProformaIssuedInvoiceRecipientSchema, recipient: IssueProformaIssuedInvoiceRecipientSchema,
tax_regime: TaxRegimeRefSchema, tax_regime: TaxRegimeRefSchema,
fiscal_classification: IssuedInvoiceFiscalClassificationSchema.nullable(),
verifactu_policy: IssuedInvoiceVerifactuPolicySchema.nullable(),
totals: IssueProformaIssuedInvoiceTotalsSchema, totals: IssueProformaIssuedInvoiceTotalsSchema,
}); });

View File

@ -0,0 +1,26 @@
import { z } from "zod/v4";
import {
AutoRenewModeSchema,
CompanyVerifactuProfileStatusSchema,
} from "../../request/settings";
export const CompanyVerifactuProfileResponseSchema = z.object({
profile_status: CompanyVerifactuProfileStatusSchema,
configured: z.boolean(),
uses_verifactu: z.boolean(),
exemption_reason_code: z.string().nullable(),
exemption_reason_detail: z.string().nullable(),
verifactu_effective_at: z.iso.datetime().nullable(),
auto_renew_mode: AutoRenewModeSchema.nullable(),
defaults: z.object({
tax_regime_code: z.string().nullable(),
operation_qualification_code: z.string().nullable(),
exemption_reason_code: z.string().nullable(),
}),
updated_at: z.iso.datetime().nullable(),
});
export type CompanyVerifactuProfileResponseDTO = z.infer<
typeof CompanyVerifactuProfileResponseSchema
>;

View File

@ -0,0 +1 @@
export * from "./company-verifactu-profile.response.dto";

View File

@ -1,5 +1,7 @@
export * from "./issued-invoice-fiscal-classification.dto";
export * from "./issued-invoice-item-detail.dto"; export * from "./issued-invoice-item-detail.dto";
export * from "./issued-invoice-recipient-summary.dto"; export * from "./issued-invoice-recipient-summary.dto";
export * from "./issued-invoice-status.dto"; export * from "./issued-invoice-status.dto";
export * from "./issued-invoice-summary.dto"; export * from "./issued-invoice-summary.dto";
export * from "./issued-invoice-verifactu-policy.dto";
export * from "./verifactu-record.dto"; export * from "./verifactu-record.dto";

View File

@ -0,0 +1,14 @@
import { z } from "zod/v4";
export const IssuedInvoiceFiscalClassificationSchema = z.object({
tax_regime_code: z.string(),
tax_regime_label: z.string().nullable(),
operation_qualification_code: z.string().nullable(),
operation_qualification_label: z.string().nullable(),
exemption_reason_code: z.string().nullable(),
exemption_reason_label: z.string().nullable(),
});
export type IssuedInvoiceFiscalClassificationDTO = z.infer<
typeof IssuedInvoiceFiscalClassificationSchema
>;

View File

@ -0,0 +1,18 @@
import { z } from "zod/v4";
const CompanyVerifactuProfileStatusSchema = z.enum([
"not_configured",
"exempt",
"obligated_without_verifactu",
"obligated_with_verifactu",
]);
export const IssuedInvoiceVerifactuPolicySchema = z.object({
profile_status: CompanyVerifactuProfileStatusSchema,
uses_verifactu: z.boolean(),
effective_at: z.iso.datetime().nullable(),
});
export type IssuedInvoiceVerifactuPolicyDTO = z.infer<
typeof IssuedInvoiceVerifactuPolicySchema
>;

View File

@ -867,5 +867,118 @@
"total_amount": "Total" "total_amount": "Total"
} }
} }
},
"settings": {
"verifactu": {
"title": "VeriFactu",
"description": "Configure how VeriFactu applies to the active company.",
"initial_mode_hint": "Choose the company situation to complete the initial configuration.",
"status": {
"title": "Situation",
"label": "Company situation",
"description": "Define whether the company is exempt, obligated without VeriFactu, or obligated with VeriFactu.",
"help": "This status describes the company policy for the VeriFactu system.",
"exempt": "Exempt or not applicable",
"obligated_without_verifactu": "Obligated without VeriFactu",
"obligated_with_verifactu": "Obligated with VeriFactu"
},
"configuration": {
"title": "VeriFactu configuration",
"description": "Only the fields that apply to the selected status are shown."
},
"exemption_reason": {
"unavailable": "The backend catalog for exemption reasons is not available in this version yet. You can save a free-text detail while leaving the code empty."
},
"exemption_detail": {
"label": "Exemption detail",
"description": "Free-text detail to document the exemption reason when it applies.",
"placeholder": "Describe the exemption reason"
},
"effective_at": {
"label": "Effective date",
"description": "Civil date from which the company operates with VeriFactu.",
"placeholder": "Select a date"
},
"auto_renew": {
"label": "Auto renewal",
"description": "Only applies when the company operates with VeriFactu.",
"placeholder": "Select a mode",
"enabled": "Enabled",
"disabled": "Disabled"
},
"defaults": {
"title": "Default values",
"description": "Only fiscal defaults with consolidated backend support are shown in this V1.",
"pending_catalogs": "Operation qualification and exemption reason catalogs are not available in backend yet, so they are not rendered as editable controls in this V1.",
"tax_regime": {
"label": "Tax regime",
"description": "Fiscal classification default for new operations when no more specific value exists.",
"placeholder": "Select a tax regime"
}
},
"actions": {
"save": "Save",
"saving": "Saving...",
"reset": "Restore"
},
"feedback": {
"idle": "Form ready.",
"saving": "Saving VeriFactu configuration.",
"no_changes": {
"title": "No changes",
"message": "You have not made any changes."
},
"success": {
"title": "Configuration saved",
"message": "VeriFactu changes were saved successfully."
},
"validation": {
"title": "Review the fields",
"message": "There are validation errors in the form."
},
"error": {
"title": "The configuration could not be saved",
"message": "Review the data and try again."
}
},
"page_error": {
"actions": {
"retry": "Retry",
"retrying": "Retrying..."
},
"forbidden": {
"title": "You do not have access to this configuration",
"description": "You do not have permission to view the VeriFactu configuration for the active company."
},
"notFound": {
"title": "Configuration unavailable",
"description": "The requested configuration is not available."
},
"network": {
"title": "Could not connect to the server",
"description": "Check your connection and try again."
},
"timeout": {
"title": "Loading is taking too long",
"description": "The server did not respond within the expected time."
},
"server": {
"title": "The configuration could not be loaded",
"description": "An internal error occurred while retrieving the VeriFactu configuration."
},
"unavailable": {
"title": "Service temporarily unavailable",
"description": "The VeriFactu service is not available right now."
},
"unknown": {
"title": "The configuration could not be loaded",
"description": "An unexpected error occurred while loading the VeriFactu configuration."
},
"missing_entity": {
"title": "The configuration could not be loaded",
"description": "The response does not contain a valid VeriFactu profile."
}
}
}
} }
} }

View File

@ -860,5 +860,118 @@
"total_amount": "Total" "total_amount": "Total"
} }
} }
},
"settings": {
"verifactu": {
"title": "VeriFactu",
"description": "Configura cómo se aplica VeriFactu a la empresa activa.",
"initial_mode_hint": "Selecciona la situación de la empresa para completar la configuración inicial.",
"status": {
"title": "Situación",
"label": "Situación de la empresa",
"description": "Define si la empresa está exenta, obligada sin VeriFactu o obligada con VeriFactu.",
"help": "Este estado describe la situación empresarial frente al sistema VeriFactu.",
"exempt": "Exenta o no aplicable",
"obligated_without_verifactu": "Obligada sin VeriFactu",
"obligated_with_verifactu": "Obligada con VeriFactu"
},
"configuration": {
"title": "Configuración VeriFactu",
"description": "Muestra solo los campos aplicables al estado seleccionado."
},
"exemption_reason": {
"unavailable": "El catálogo backend de motivos de exención todavía no está disponible en esta versión. Puedes guardar un detalle libre dejando el código vacío."
},
"exemption_detail": {
"label": "Detalle de exención",
"description": "Detalle libre para documentar el motivo de exención cuando aplique.",
"placeholder": "Describe el motivo de exención"
},
"effective_at": {
"label": "Fecha efectiva",
"description": "Fecha civil a partir de la cual la empresa opera con VeriFactu.",
"placeholder": "Selecciona una fecha"
},
"auto_renew": {
"label": "Renovación automática",
"description": "Solo aplica cuando la empresa opera con VeriFactu.",
"placeholder": "Selecciona un modo",
"enabled": "Activada",
"disabled": "Desactivada"
},
"defaults": {
"title": "Valores por defecto",
"description": "Solo se muestran los defaults fiscales con soporte backend consolidado en esta versión.",
"pending_catalogs": "Los catálogos de calificación de operación y motivo de exención todavía no están disponibles en backend, por lo que no se muestran como controles editables en esta V1.",
"tax_regime": {
"label": "Régimen fiscal",
"description": "Default de clasificación fiscal para nuevas operaciones cuando no exista un valor más específico.",
"placeholder": "Selecciona un régimen fiscal"
}
},
"actions": {
"save": "Guardar",
"saving": "Guardando...",
"reset": "Restaurar"
},
"feedback": {
"idle": "Formulario listo.",
"saving": "Guardando configuración de VeriFactu.",
"no_changes": {
"title": "Sin cambios",
"message": "No has realizado ningún cambio."
},
"success": {
"title": "Configuración guardada",
"message": "Los cambios de VeriFactu se han guardado correctamente."
},
"validation": {
"title": "Revisa los campos",
"message": "Hay errores de validación en el formulario."
},
"error": {
"title": "No se pudo guardar la configuración",
"message": "Revisa los datos e inténtalo de nuevo."
}
},
"page_error": {
"actions": {
"retry": "Reintentar",
"retrying": "Reintentando..."
},
"forbidden": {
"title": "No tienes acceso a esta configuración",
"description": "No dispones de permisos para consultar la configuración VeriFactu de la empresa activa."
},
"notFound": {
"title": "Configuración no disponible",
"description": "La configuración solicitada no está disponible."
},
"network": {
"title": "No se pudo conectar con el servidor",
"description": "Comprueba tu conexión e inténtalo de nuevo."
},
"timeout": {
"title": "La carga está tardando demasiado",
"description": "El servidor no respondió dentro del tiempo esperado."
},
"server": {
"title": "No se pudo cargar la configuración",
"description": "Se produjo un error interno al recuperar la configuración VeriFactu."
},
"unavailable": {
"title": "Servicio temporalmente no disponible",
"description": "El servicio de VeriFactu no está disponible en este momento."
},
"unknown": {
"title": "No se pudo cargar la configuración",
"description": "Ha ocurrido un error inesperado al cargar la configuración VeriFactu."
},
"missing_entity": {
"title": "No se pudo cargar la configuración",
"description": "La respuesta recibida no contiene un perfil VeriFactu válido."
}
}
}
} }
} }

View File

@ -42,6 +42,10 @@ const IssuedInvoiceDetailPage = lazy(() =>
import("./issued-invoices/detail").then((m) => ({ default: m.IssuedInvoiceDetailPage })) import("./issued-invoices/detail").then((m) => ({ default: m.IssuedInvoiceDetailPage }))
); );
const CompanyVerifactuSettingsPage = lazy(() =>
import("./settings/verifactu").then((m) => ({ default: m.CompanyVerifactuSettingsPage }))
);
export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[] => { export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[] => {
return [ return [
{ {
@ -154,5 +158,13 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
</IssuedInvoicesLayout> </IssuedInvoicesLayout>
), ),
}, },
{
path: "settings/billing/verifactu",
handle: {
layout: "app-sidebar",
protected: true,
},
element: <CompanyVerifactuSettingsPage />,
},
]; ];
}; };

View File

@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import test from "node:test";
import { CustomerInvoicesModuleManifest } from "./manifest";
test("publica la entry de Settings para VeriFactu", () => {
assert.deepEqual(CustomerInvoicesModuleManifest.settings, {
entries: [
{
id: "verifactu",
sectionId: "billing",
labelKey: "settings.verifactu.title",
path: "/settings/billing/verifactu",
},
],
});
});
test("expone una ruta modular para settings/billing/verifactu", () => {
const routes = CustomerInvoicesModuleManifest.routes?.({});
const settingsRoute = routes?.find((route) => route.path === "settings/billing/verifactu");
assert.ok(settingsRoute);
assert.equal(settingsRoute?.handle?.layout, "app-sidebar");
assert.equal(settingsRoute?.handle?.protected, true);
});

Some files were not shown because too many files have changed in this diff Show More