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:
parent
ba1a424393
commit
45200284ed
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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 },
|
||||
];
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
191
apps/web/src/settings/build-settings-navigation.test.ts
Normal file
191
apps/web/src/settings/build-settings-navigation.test.ts
Normal 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);
|
||||
});
|
||||
58
apps/web/src/settings/build-settings-navigation.ts
Normal file
58
apps/web/src/settings/build-settings-navigation.ts
Normal 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);
|
||||
};
|
||||
127
apps/web/src/settings/build-settings-routes.test.tsx
Normal file
127
apps/web/src/settings/build-settings-routes.test.tsx
Normal 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/);
|
||||
});
|
||||
78
apps/web/src/settings/build-settings-routes.tsx
Normal file
78
apps/web/src/settings/build-settings-routes.tsx
Normal 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" />,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
6
apps/web/src/settings/index.ts
Normal file
6
apps/web/src/settings/index.ts
Normal 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";
|
||||
16
apps/web/src/settings/settings-index-page.tsx
Normal file
16
apps/web/src/settings/settings-index-page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
37
apps/web/src/settings/settings-layout.tsx
Normal file
37
apps/web/src/settings/settings-layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
121
apps/web/src/settings/settings-navigation.test.tsx
Normal file
121
apps/web/src/settings/settings-navigation.test.tsx
Normal 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/);
|
||||
});
|
||||
62
apps/web/src/settings/settings-navigation.tsx
Normal file
62
apps/web/src/settings/settings-navigation.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
15
apps/web/src/settings/settings-section-catalog.ts
Normal file
15
apps/web/src/settings/settings-section-catalog.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
@ -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
|
||||
```
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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'
|
||||
-- );
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./company-verifactu-profile-use-cases.di";
|
||||
@ -0,0 +1,4 @@
|
||||
export * from "./di";
|
||||
export * from "./repositories";
|
||||
export * from "./services";
|
||||
export * from "./use-cases";
|
||||
@ -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>>;
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./company-verifactu-profile.repository.interface";
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./company-verifactu-profile-response-builder";
|
||||
@ -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);
|
||||
});
|
||||
@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./get-company-verifactu-profile.use-case";
|
||||
export * from "./update-company-verifactu-profile.use-case";
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./company-verifactu-profile";
|
||||
export * from "./invoice-series";
|
||||
export * from "./issued-invoices";
|
||||
export * from "./proformas";
|
||||
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
|
||||
@ -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[];
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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);
|
||||
});
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./company-verifactu-profile.aggregate";
|
||||
export * from "./company-verifactu-profile.types";
|
||||
@ -1,4 +1,5 @@
|
||||
export * from "./common";
|
||||
export * from "./company-verifactu-profile";
|
||||
export * from "./invoice-series";
|
||||
export * from "./issued-invoices";
|
||||
export * from "./proformas";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
});
|
||||
});
|
||||
@ -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()),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./company-verifactu-profile.di";
|
||||
@ -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);
|
||||
};
|
||||
@ -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);
|
||||
});
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./get-company-verifactu-profile.controller";
|
||||
export * from "./update-company-verifactu-profile.controller";
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./company-verifactu-profile.routes";
|
||||
export * from "./controllers";
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "./di";
|
||||
export * from "./express";
|
||||
export * from "./persistence";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./sequelize";
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./mappers";
|
||||
export * from "./repositories";
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./company-verifactu-profile-domain.mapper";
|
||||
@ -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;
|
||||
}
|
||||
});
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./company-verifactu-profile.repository";
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./common";
|
||||
export * from "./company-verifactu-profile";
|
||||
export * from "./issued-invoices";
|
||||
export * from "./proformas";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./issued-invoices";
|
||||
export * from "./proformas";
|
||||
export * from "./settings";
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export * from "./update-company-verifactu-profile.request.dto";
|
||||
@ -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
|
||||
>;
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./issued-invoices";
|
||||
export * from "./proformas";
|
||||
export * from "./settings";
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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
|
||||
>;
|
||||
@ -0,0 +1 @@
|
||||
export * from "./company-verifactu-profile.response.dto";
|
||||
@ -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";
|
||||
|
||||
@ -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
|
||||
>;
|
||||
@ -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
|
||||
>;
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 />,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
26
modules/customer-invoices/src/web/manifest.test.ts
Normal file
26
modules/customer-invoices/src/web/manifest.test.ts
Normal 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
Loading…
Reference in New Issue
Block a user