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[];
params: ModuleClientParams;
layout: ModuleRouteLayout;
excludePathPrefix?: string;
pathPrefix?: string;
}
interface NormalizeRouteParams {
@ -27,6 +29,99 @@ interface NormalizeRouteParams {
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 = (
route: ModuleRouteObject,
module: IModuleClient,
@ -127,8 +222,12 @@ export const buildModuleRoutes = ({
modules,
params,
layout,
excludePathPrefix,
pathPrefix,
}: BuildModuleRoutesParams): RouteObject[] => {
const routes: RouteObject[] = [];
const normalizedPathPrefix = normalizePathPrefix(pathPrefix);
const normalizedExcludePathPrefix = normalizePathPrefix(excludePathPrefix);
for (const module of modules) {
if (typeof module.routes !== "function") {
@ -153,8 +252,18 @@ export const buildModuleRoutes = ({
targetLayout: layout,
});
if (normalizedRoute) {
routes.push(normalizedRoute);
if (!normalizedRoute) {
continue;
}
const mappedRoute = mapRouteToPathPrefix({
route: normalizedRoute,
pathPrefix: normalizedPathPrefix,
excludePathPrefix: normalizedExcludePathPrefix,
});
if (mappedRoute) {
routes.push(mappedRoute);
}
}
}

View File

@ -14,30 +14,10 @@ import {
TooltipTrigger,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import {
Building2Icon,
ChevronRightIcon,
CoinsIcon,
PercentIcon,
SettingsIcon,
SlidersHorizontalIcon,
UserCogIcon,
} from "lucide-react";
import { ChevronRightIcon, SettingsIcon } from "lucide-react";
import { Link } from "react-router-dom";
interface AppSidebarSettingsItem {
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 },
];
import { appSidebarSettingsItems } from "./app-sidebar.config";
interface AppSidebarSettingsMenuProps {
className?: string;
@ -80,7 +60,7 @@ export const AppSidebarSettingsMenu = ({ className }: AppSidebarSettingsMenuProp
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{settingsItems.map((item) => {
{appSidebarSettingsItems.map((item) => {
const Icon = item.icon;
return (

View File

@ -3,20 +3,18 @@ import {
BanknoteIcon,
BarChart3Icon,
BoxesIcon,
Building2Icon,
ClipboardListIcon,
CoinsIcon,
CreditCardIcon,
FileTextIcon,
LandmarkIcon,
PackageIcon,
PercentIcon,
ReceiptIcon,
RefreshCcwIcon,
ShoppingCartIcon,
SlidersHorizontalIcon,
SettingsIcon,
ShieldCheckIcon,
TruckIcon,
UserCogIcon,
UsersIcon,
WarehouseIcon,
} from "lucide-react";
@ -116,9 +114,6 @@ export interface AppSidebarSettingsItem {
}
export const appSidebarSettingsItems: 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 },
{ title: "Configuración", href: "/settings", icon: SettingsIcon },
{ title: "Verifactu", href: "/settings/billing/verifactu", icon: ShieldCheckIcon },
];

View File

@ -33,6 +33,19 @@
"retrying": "Retrying…",
"goHome": "Go to home",
"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…",
"goHome": "Ir al inicio",
"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 ensureAppTranslations = () => {
export const ensureAppTranslations = () => {
registerTranslations(APP_MODULE_NAME, "es", esResources);
registerTranslations(APP_MODULE_NAME, "en", enResources);
};
ensureAppTranslations();
/**
* Hook de traducción específico de la app principal.
*/
export const useAppTranslation = () => {
ensureAppTranslations();
// biome-ignore lint/correctness/useExhaustiveDependencies: <Registro idempotente ligado a la instancia compartida>
useEffect(() => {
ensureAppTranslations();

View File

@ -13,6 +13,7 @@ import {
} from "@/layout";
import { UiKitPage } from "@/development/ui-kit";
import ShadcnShowcasePage from "@/pages/shadcn-ui-page";
import { buildSettingsRoute } from "@/settings";
import TailwindV4ShowcasePage from "@/pages/tailwindcss-page";
import { RouteErrorPage, StaticRouteErrorPage } from "../pages";
@ -50,6 +51,7 @@ export const getAppRouter = () => {
const appSidebarRoutes = buildModuleRoutes({
modules,
params,
excludePathPrefix: "settings",
layout: "app-sidebar",
});
@ -119,10 +121,6 @@ export const getAppRouter = () => {
path: "dashboard",
element: <StaticRouteErrorPage variant="notFound" />,
},
{
path: "settings",
element: <StaticRouteErrorPage variant="notFound" />,
},
{
path: "catalog",
element: <StaticRouteErrorPage variant="notFound" />,
@ -131,6 +129,10 @@ export const getAppRouter = () => {
path: "quotes",
element: <StaticRouteErrorPage variant="notFound" />,
},
buildSettingsRoute({
modules,
params,
}),
...developmentRoutes,
...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
- [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)
#### Customer invoices y proformas
- [Í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 create de proformas](./customer-invoices/proforma-create-contract.md)
- [Configuración fiscal persistida en proformas](./customer-invoices/proforma-tax-config.md)

View File

@ -6,6 +6,7 @@
### 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 create de proformas](./proforma-create-contract.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
- `add-company-verifactu-profiles.sql`
- `add-issued-invoice-verifactu-snapshots.sql`
- `add-proforma-document-series-columns.sql`
- `add-proforma-tax-config-columns.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.
2. Hacer backup o confirmar que los datos son descartables.
3. Ejecutar el DDL vigente que falte para `proformas`.
4. Ejecutar `normalize-document-series-snapshots.sql` para completar snapshots y validaciones de seriado.
5. Ejecutar las validaciones SQL vigentes.
6. Levantar backend V2 y validar flujos funcionales.
3. Ejecutar `add-company-verifactu-profiles.sql` si el entorno aún no tiene el perfil VeriFactu empresarial.
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 el DDL vigente que falte para `proformas`.
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

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`.
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:
```txt

View File

@ -218,10 +218,12 @@ apps/web
│ └── rutas de módulos bajo namespace /settings/...
│
└── settings/
├── settings-sections.ts
├── build-settings-entries.ts
├── settings-section-catalog.ts
├── build-settings-navigation.ts
├── build-settings-routes.tsx
├── settings-layout.tsx
└── settings-navigation.tsx
├── settings-navigation.tsx
└── settings-index-page.tsx
▲
│
metadata ligera
@ -270,11 +272,13 @@ export interface ModuleSettingsEntryContribution {
path: string;
}
export interface ModuleSettingsContribution {
entries: ModuleSettingsEntryContribution[];
}
export interface IModuleClient {
// contrato existente
settings?: {
entries: ModuleSettingsEntryContribution[];
};
settings?: ModuleSettingsContribution;
}
```
@ -531,21 +535,17 @@ SettingsNavigation
## 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.
### 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.
La elección sigue sin 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:
1. Shape exacto de `settings` dentro de `IModuleClient`.
2. Cómo materializar el subtree `/settings/*` reutilizando `buildModuleRoutes()`.
3. Comportamiento exacto de `/settings`: índice o redirección.
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.
1. Si `order` resulta necesario en el primer segundo consumidor.
2. Cuándo introducir `permission?: PermissionCode`.
3. Si en el futuro se necesita namespace i18n explícito.
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 interface ModuleSettingsEntryContribution {
id: string;
sectionId: string;
labelKey: string;
path: string;
}
export interface ModuleSettingsContribution {
entries: ModuleSettingsEntryContribution[];
}
export interface ModuleRouteHandle {
layout?: ModuleRouteLayout;
protected?: boolean;
@ -33,5 +44,7 @@ export interface IModuleClient extends ModuleMetadata {
icon?: ReactNode;
settings?: ModuleSettingsContribution;
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 "./issued-invoices";
export * from "./proformas";

View File

@ -1,3 +1,4 @@
import type { CompanyVerifactuProfileStatus } from "../../../domain";
import type { Proforma } from "../../../domain";
import type {
InvocieTaxRegimeFullReadModel,
@ -14,4 +15,13 @@ export interface ProformaIssueReadModel {
proforma: Proforma;
paymentMethod: InvoicePaymentMethodReadModel;
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-public-services.interface";
export * from "./proforma-to-issued-invoice-props-converter";
export * from "./resolve-proforma-issue-verifactu-snapshots";

View File

@ -9,10 +9,12 @@ import {
type InvoiceSerie,
InvoiceStatus,
InvoiceTaxRegime,
IssuedInvoiceFiscalClassification,
IssuedInvoiceItem,
IssuedInvoiceRecipient,
IssuedInvoiceTax,
IssuedInvoiceTaxes,
IssuedInvoiceVerifactuPolicy,
type Proforma,
} from "../../../domain";
import type { ProformaIssueReadModel } from "../models";
@ -70,6 +72,18 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
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();
return Result.ok({
@ -92,6 +106,8 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
paymentMethod: paymentResult.data,
taxRegime: taxRegimeResult.data,
fiscalClassification: Maybe.some(fiscalClassificationResult.data),
verifactuPolicy: Maybe.some(verifactuPolicyResult.data),
customerId: proforma.customerId,
recipient: recipientResult.data,
@ -278,4 +294,48 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
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,
};
const fiscal_classification = maybeToNullable(invoice.fiscalClassification, (value) =>
value.toObjectString()
);
const verifactu_policy = maybeToNullable(invoice.verifactuPolicy, (value) =>
value.toObjectString()
);
return {
id: invoice.id.toString(),
company_id: invoice.companyId.toString(),
@ -68,6 +75,8 @@ export class IssuedInvoiceFullSnapshotBuilder implements IIssuedInvoiceFullSnaps
payment_method,
tax_regime,
fiscal_classification,
verifactu_policy,
subtotal_amount: invoice.subtotalAmount.toObjectString(),

View File

@ -51,6 +51,19 @@ export interface IIssuedInvoiceFullSnapshot {
code: string;
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 };
@ -67,7 +80,6 @@ export interface IIssuedInvoiceFullSnapshot {
taxes_amount: { value: string; scale: string; currency_code: string };
total_amount: { value: string; scale: string; currency_code: string };
verifactu: IIssuedInvoiceVerifactuFullSnapshot;
items: IIssuedInvoiceItemFullSnapshot[];

View File

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

View File

@ -1,6 +1,8 @@
import type { IPaymentMethodPublicFinder, ITaxRegimePublicFinder } from "@erp/catalogs/api";
import type { ICustomerPublicServices } from "@erp/customers/api";
import type { IProformaToIssuedInvoiceConverter } from "../../issued-invoices";
import type { ICompanyVerifactuProfileRepository } from "../../company-verifactu-profile";
import { type IProformaIssuer, ProformaIssueReadModelAssembler, ProformaIssuer } from "../services";
export const buildProformaIssuer = (params: {
@ -14,9 +16,13 @@ export const buildProformaIssuer = (params: {
export const buildProformaIssueReadModelAssembler = (params: {
paymentMethodFinder: IPaymentMethodPublicFinder;
taxRegimeFinder: ITaxRegimePublicFinder;
customerServices: ICustomerPublicServices;
companyVerifactuProfileRepository: ICompanyVerifactuProfileRepository;
}) => {
return new ProformaIssueReadModelAssembler({
paymentMethodFinder: params.paymentMethodFinder,
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 { IProformaRepository } from "../repositories";
import type {
IProformaArchiver,
ICompanyReportProfileFinder,
IProformaDeleter,
IProformaArchiver,
IProformaCreator,
IProformaDeleter,
IProformaFinder,
IProformaFullReadModelAssembler,
IProformaIssueReadModelAssembler,
@ -17,8 +17,8 @@ import type {
ProformaDocumentGeneratorService,
} from "../services";
import type {
IProformaFullSnapshotBuilder,
IIssueProformaResponseSnapshotBuilder,
IProformaFullSnapshotBuilder,
IProformaReportSnapshotBuilder,
IProformaSummarySnapshotBuilder,
} from "../snapshot-builders";
@ -26,8 +26,8 @@ import {
ArchiveProformaByIdUseCase,
ChangeStatusProformaUseCase,
CreateProformaUseCase,
DuplicateProformaByIdUseCase,
DeleteProformaByIdUseCase,
DuplicateProformaByIdUseCase,
GetProformaByIdUseCase,
IssueProformaUseCase,
ListProformasUseCase,

View File

@ -1,9 +1,12 @@
import type { IPaymentMethodPublicFinder, ITaxRegimePublicFinder } from "@erp/catalogs/api";
import type { ICustomerPublicServices } from "@erp/customers/api";
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 { ICompanyVerifactuProfileRepository } from "../../../company-verifactu-profile";
import type { ProformaIssueReadModel } from "../../../issued-invoices";
import { resolveProformaIssueVerifactuSnapshots } from "../../../issued-invoices";
export interface IProformaIssueReadModelAssembler {
assemble(params: {
@ -24,6 +27,8 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA
private readonly deps: {
paymentMethodFinder: IPaymentMethodPublicFinder;
taxRegimeFinder: ITaxRegimePublicFinder;
customerServices: ICustomerPublicServices;
companyVerifactuProfileRepository: ICompanyVerifactuProfileRepository;
}
) {}
@ -48,7 +53,35 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA
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) {
return Result.fail(taxRegimeResult.error);
@ -64,19 +97,31 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA
description: paymentMethod.description,
},
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: {
companyId: UniqueID;
proforma: Proforma;
taxRegimeCode: Maybe<string>;
transaction?: unknown;
}) {
if (params.proforma.taxRegimeCode.isNone()) {
if (params.taxRegimeCode.isNone()) {
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({
companyId: params.companyId,

View File

@ -1,4 +1,5 @@
import type { ISnapshotBuilder } from "@erp/core/api";
import { maybeToNullable } from "@repo/rdx-ddd";
import type { IssueProformaByIdResponseDTO } from "../../../../../common";
import type { IssuedInvoice } from "../../../../domain";
@ -27,6 +28,12 @@ export class IssueProformaResponseSnapshotBuilder implements IIssueProformaRespo
code: invoice.taxRegime.code,
description: invoice.taxRegime.description,
},
fiscal_classification: maybeToNullable(invoice.fiscalClassification, (value) =>
value.toObjectString()
),
verifactu_policy: maybeToNullable(invoice.verifactuPolicy, (value) =>
value.toObjectString()
),
totals: {
taxable_base: invoice.taxableAmount.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 "./company-verifactu-profile";
export * from "./invoice-series";
export * from "./issued-invoices";
export * from "./proformas";

View File

@ -27,7 +27,11 @@ import {
type VerifactuRecord,
} from "../entities";
import { IssuedInvoiceItemMismatch } from "../errors";
import type { IssuedInvoiceRecipient } from "../value-objects";
import type {
IssuedInvoiceFiscalClassification,
IssuedInvoiceRecipient,
IssuedInvoiceVerifactuPolicy,
} from "../value-objects";
export interface IIssuedInvoiceCreateProps {
companyId: UniqueID;
@ -75,6 +79,9 @@ export interface IIssuedInvoiceCreateProps {
taxesAmount: InvoiceAmount;
totalAmount: InvoiceAmount;
fiscalClassification: Maybe<IssuedInvoiceFiscalClassification>;
verifactuPolicy: Maybe<IssuedInvoiceVerifactuPolicy>;
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();
}
@ -270,6 +297,14 @@ export class IssuedInvoice
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 {
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-tax-group.vo";
export * from "./issued-invoice-verifactu-policy.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 {
buildCompanyVerifactuProfileDependencies,
companyVerifactuProfileRouter,
buildIssuedInvoicePublicServices,
buildIssuedInvoicesDependencies,
buildProformaPublicServices,
@ -28,6 +30,7 @@ export const customerInvoicesAPIModule: IModuleServer = {
const { logger } = params;
// 1) Dominio interno
const companyVerifactuProfileInternal = buildCompanyVerifactuProfileDependencies(params);
const issuedInvoicesInternal = buildIssuedInvoicesDependencies(params);
const proformasInternal = buildProformasDependencies(params);
@ -49,6 +52,7 @@ export const customerInvoicesAPIModule: IModuleServer = {
// Implementación privada del módulo
internal: {
companyVerifactuProfile: companyVerifactuProfileInternal,
issuedInvoices: issuedInvoicesInternal,
proformas: proformasInternal,
},
@ -66,6 +70,7 @@ export const customerInvoicesAPIModule: IModuleServer = {
const { logger } = params;
// Registro de rutas HTTP
companyVerifactuProfileRouter(params);
issuedInvoicesRouter(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 issuedInvoiceItemModelInit from "./models/sequelize-issued-invoice-item.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.
export const models = [
companyVerifactuProfileModelInit,
proformaModelInit,
proformaItemModelInit,
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-item.model";
export * from "./sequelize-proforma-tax.model";

View File

@ -73,6 +73,11 @@ export class IssuedInvoiceModel extends Model<
// Régimen fiscal
declare tax_regime_code: 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
declare subtotal_amount_value: number;
@ -336,6 +341,31 @@ export default (database: Sequelize) => {
type: new DataTypes.STRING(),
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: {
type: new DataTypes.BIGINT(),
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 "./company-verifactu-profile";
export * from "./issued-invoices";
export * from "./proformas";

View File

@ -12,9 +12,10 @@ import {
maybeFromNullableResult,
maybeToNullable,
} from "@repo/rdx-ddd";
import { Result, isNullishOrEmpty } from "@repo/rdx-utils";
import { Maybe, Result, isNullishOrEmpty } from "@repo/rdx-utils";
import {
type CompanyVerifactuProfileStatus,
type InternalIssuedInvoiceProps,
InvoiceAmount,
InvoiceNumber,
@ -22,9 +23,11 @@ import {
InvoiceSerie,
InvoiceStatus,
InvoiceTaxRegime,
IssuedInvoiceFiscalClassification,
IssuedInvoice,
IssuedInvoiceItems,
IssuedInvoiceTaxes,
IssuedInvoiceVerifactuPolicy,
} from "../../../../../../domain";
import type { IssuedInvoiceCreationAttributes, IssuedInvoiceModel } from "../../../../../common";
@ -221,6 +224,7 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
"total_amount_value",
errors
);
const hasVerifactuSnapshots = raw.verifactu_profile_status != null;
return {
invoiceId,
@ -252,6 +256,7 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
retentionAmount,
taxesAmount,
totalAmount,
hasVerifactuSnapshots,
};
}
@ -302,6 +307,58 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
languageCode: attributes.languageCode!,
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 = {
companyId: attributes.companyId!,
@ -332,6 +389,8 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
totalAmount: attributes.totalAmount!,
paymentMethod: attributes.paymentMethod!,
taxRegime: attributes.taxRegime!,
fiscalClassification,
verifactuPolicy,
taxes,
verifactu: verifactuResult.data,
linkedProformaId: attributes.linkedProformaId!,
@ -381,7 +440,7 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
);
}
return Result.ok({
const persistence: IssuedInvoiceCreationAttributes = {
id: source.id.toPrimitive(),
company_id: source.companyId.toPrimitive(),
status: source.status.toPrimitive(),
@ -406,6 +465,21 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
payment_term_snapshot_json: null,
tax_regime_code: source.taxRegime.code,
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_scale: source.subtotalAmount.scale,
items_discount_amount_value: source.itemsDiscountAmount.value,
@ -433,6 +507,8 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
taxes: taxesResult.data,
items: itemsResult.data,
verifactu: verifactuResult.data,
} satisfies IssuedInvoiceCreationAttributes);
};
return Result.ok(persistence);
}
}

View File

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

View File

@ -1,2 +1,3 @@
export * from "./issued-invoices";
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 "./proformas";
export * from "./settings";

View File

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

View File

@ -1,7 +1,12 @@
import { CurrencyCodeSchema, IsoDateSchema, MoneySchema } from "@erp/core";
import { z } from "zod/v4";
import { IssuedInvoiceStatusSchema, TaxRegimeRefSchema } from "../../shared";
import {
IssuedInvoiceFiscalClassificationSchema,
IssuedInvoiceStatusSchema,
IssuedInvoiceVerifactuPolicySchema,
TaxRegimeRefSchema,
} from "../../shared";
export const IssueProformaIssuedInvoiceRecipientSchema = z.object({
name: z.string(),
@ -28,6 +33,8 @@ export const IssueProformaIssuedInvoiceSchema = z.object({
status: IssuedInvoiceStatusSchema,
recipient: IssueProformaIssuedInvoiceRecipientSchema,
tax_regime: TaxRegimeRefSchema,
fiscal_classification: IssuedInvoiceFiscalClassificationSchema.nullable(),
verifactu_policy: IssuedInvoiceVerifactuPolicySchema.nullable(),
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-recipient-summary.dto";
export * from "./issued-invoice-status.dto";
export * from "./issued-invoice-summary.dto";
export * from "./issued-invoice-verifactu-policy.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"
}
}
},
"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"
}
}
},
"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 }))
);
const CompanyVerifactuSettingsPage = lazy(() =>
import("./settings/verifactu").then((m) => ({ default: m.CompanyVerifactuSettingsPage }))
);
export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[] => {
return [
{
@ -154,5 +158,13 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
</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