From 45200284ed88877022412e316b7ea32e9736e4e1 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 12 Aug 2026 13:31:31 +0200 Subject: [PATCH] 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. --- apps/web/src/components/module-routes.tsx | 113 +++- .../src/layout/app-sidebar-settings-menu.tsx | 26 +- apps/web/src/layout/app-sidebar.config.ts | 13 +- apps/web/src/locales/en.json | 13 + apps/web/src/locales/es.json | 13 + apps/web/src/locales/i18n.ts | 6 +- apps/web/src/routes/app-routes.tsx | 10 +- .../build-settings-navigation.test.ts | 191 ++++++ .../src/settings/build-settings-navigation.ts | 58 ++ .../settings/build-settings-routes.test.tsx | 127 ++++ .../src/settings/build-settings-routes.tsx | 78 +++ apps/web/src/settings/index.ts | 6 + apps/web/src/settings/settings-index-page.tsx | 16 + apps/web/src/settings/settings-layout.tsx | 37 ++ .../src/settings/settings-navigation.test.tsx | 121 ++++ apps/web/src/settings/settings-navigation.tsx | 62 ++ .../src/settings/settings-section-catalog.ts | 15 + docs/README.md | 4 + docs/customer-invoices/README.md | 1 + .../frontend-erp-settings-verifactu-design.md | 209 ++++++ ...erifactu-company-profile-backend-design.md | 628 ++++++++++++++++++ docs/customer-invoices/sql/README.md | 12 +- .../sql/add-company-verifactu-profiles.sql | 39 ++ ...add-issued-invoice-verifactu-snapshots.sql | 108 +++ ...-erp-settings-module-integration-design.md | 11 + .../frontend-erp-settings-system-design.md | 45 +- .../lib/modules/module-client.interface.ts | 13 + .../company-verifactu-profile-use-cases.di.ts | 34 + .../company-verifactu-profile/di/index.ts | 1 + .../company-verifactu-profile/index.ts | 4 + ...-verifactu-profile.repository.interface.ts | 15 + .../repositories/index.ts | 1 + ...pany-verifactu-profile-response-builder.ts | 70 ++ .../services/index.ts | 1 + ...ompany-verifactu-profile.use-cases.test.ts | 176 +++++ .../get-company-verifactu-profile.use-case.ts | 35 + .../use-cases/index.ts | 2 + ...date-company-verifactu-profile.use-case.ts | 139 ++++ .../src/api/application/index.ts | 1 + .../models/proforma-issue-read.model.ts | 10 + .../issued-invoices/services/index.ts | 1 + ...forma-to-issued-invoice-props-converter.ts | 60 ++ ...proforma-issue-verifactu-snapshots.test.ts | 129 ++++ ...olve-proforma-issue-verifactu-snapshots.ts | 69 ++ .../issued-invoice-full-snapshot-builder.ts | 9 + .../issued-invoice-full-snapshot.interface.ts | 14 +- .../proformas/di/proforma-deleter.di.ts | 2 +- .../proformas/di/proforma-issuer.di.ts | 6 + .../proformas/di/proforma-use-cases.di.ts | 8 +- .../proforma-issue-read-model.assembler.ts | 53 +- ...ssue-proforma-response.snapshot-builder.ts | 7 + ...ompany-verifactu-profile.aggregate.test.ts | 122 ++++ .../company-verifactu-profile.aggregate.ts | 252 +++++++ .../company-verifactu-profile.types.ts | 33 + .../domain/company-verifactu-profile/index.ts | 2 + .../customer-invoices/src/api/domain/index.ts | 1 + .../aggregates/issued-invoice.aggregate.ts | 37 +- .../issued-invoices/value-objects/index.ts | 2 + ...issued-invoice-fiscal-classification.vo.ts | 73 ++ ...issued-invoice-verifactu-policy.vo.test.ts | 59 ++ .../issued-invoice-verifactu-policy.vo.ts | 72 ++ modules/customer-invoices/src/api/index.ts | 5 + .../common/persistence/sequelize/index.ts | 2 + .../models/company-verifactu-profile.model.ts | 121 ++++ .../persistence/sequelize/models/index.ts | 1 + .../models/sequelize-issued-invoice.model.ts | 30 + .../di/company-verifactu-profile.di.ts | 49 ++ .../company-verifactu-profile/di/index.ts | 1 + .../company-verifactu-profile.routes.ts | 42 ++ ...pany-verifactu-profile.controllers.test.ts | 94 +++ ...et-company-verifactu-profile.controller.ts | 34 + .../express/controllers/index.ts | 2 + ...te-company-verifactu-profile.controller.ts | 40 ++ .../express/index.ts | 2 + .../company-verifactu-profile/index.ts | 3 + .../persistence/index.ts | 1 + .../persistence/sequelize/index.ts | 2 + ...company-verifactu-profile-domain.mapper.ts | 105 +++ .../persistence/sequelize/mappers/index.ts | 1 + ...mpany-verifactu-profile.repository.test.ts | 124 ++++ .../company-verifactu-profile.repository.ts | 109 +++ .../sequelize/repositories/index.ts | 1 + .../src/api/infrastructure/index.ts | 1 + ...quelize-issued-invoice-v2-domain.mapper.ts | 82 ++- .../proformas/di/proformas.di.ts | 10 + .../src/common/dto/request/index.ts | 1 + .../src/common/dto/request/settings/index.ts | 1 + ...e-company-verifactu-profile.request.dto.ts | 29 + .../src/common/dto/response/index.ts | 1 + .../get-issued-invoice-by-id.response.dto.ts | 7 +- .../issue-proforma-by-id.response.dto.ts | 9 +- .../company-verifactu-profile.response.dto.ts | 26 + .../src/common/dto/response/settings/index.ts | 1 + .../dto/shared/issued-invoices/index.ts | 2 + ...ssued-invoice-fiscal-classification.dto.ts | 14 + .../issued-invoice-verifactu-policy.dto.ts | 18 + .../src/common/locales/en.json | 113 ++++ .../src/common/locales/es.json | 113 ++++ .../src/web/customer-invoice-routes.tsx | 12 + .../src/web/manifest.test.ts | 26 + modules/customer-invoices/src/web/manifest.ts | 14 + .../settings/verifactu/controllers/index.ts | 2 + ...se-company-verifactu-profile.controller.ts | 238 +++++++ ...pany-verifactu-settings-page-controller.ts | 58 ++ .../company-verifactu-profile-form.entity.ts | 16 + .../company-verifactu-profile-form.schema.ts | 89 +++ ...u-settings-page-error-view-model.entity.ts | 9 + .../web/settings/verifactu/entities/index.ts | 3 + .../src/web/settings/verifactu/index.ts | 1 + ...-company-verifactu-profile.adapter.test.ts | 36 + .../get-company-verifactu-profile.adapter.ts | 22 + .../verifactu/shared/adapters/index.ts | 2 + ...pdate-company-verifactu-profile.adapter.ts | 10 + .../api/get-company-verifactu-profile.api.ts | 23 + .../settings/verifactu/shared/api/index.ts | 2 + .../update-company-verifactu-profile.api.ts | 25 + .../company-verifactu-profile.entity.ts | 21 + .../verifactu/shared/entities/index.ts | 1 + .../settings/verifactu/shared/hooks/index.ts | 3 + .../settings/verifactu/shared/hooks/keys.ts | 11 + .../shared/hooks/to-validation-errors.ts | 9 + .../use-company-verifactu-profile-query.ts | 27 + ...mpany-verifactu-profile-update-mutation.ts | 35 + .../web/settings/verifactu/shared/index.ts | 4 + .../company-verifactu-settings-skeleton.tsx | 16 + .../settings/verifactu/ui/components/index.ts | 1 + .../src/web/settings/verifactu/ui/index.ts | 3 + .../pages/company-verifactu-settings-page.tsx | 132 ++++ .../web/settings/verifactu/ui/pages/index.ts | 1 + ...ompany-verifactu-configuration-section.tsx | 81 +++ .../company-verifactu-defaults-section.tsx | 50 ++ .../company-verifactu-status-section.tsx | 102 +++ .../settings/verifactu/ui/sections/index.ts | 3 + .../build-company-verifactu-profile-form.ts | 27 + ...ctu-settings-page-error-view-model.test.ts | 68 ++ ...erifactu-settings-page-error-view-model.ts | 64 ++ ...e-company-verifactu-profile-params.test.ts | 87 +++ ...update-company-verifactu-profile-params.ts | 54 ++ .../src/web/settings/verifactu/utils/index.ts | 4 + ...factu-settings-page-feedback-state.test.ts | 68 ++ ...-verifactu-settings-page-feedback-state.ts | 27 + ...equelize-supplier-invoice-domain.mapper.ts | 1 - ...ze-supplier-invoice-taxes-domain.mapper.ts | 1 - .../rdx-editor-foundations.test.tsx | 8 +- 144 files changed, 6081 insertions(+), 88 deletions(-) create mode 100644 apps/web/src/settings/build-settings-navigation.test.ts create mode 100644 apps/web/src/settings/build-settings-navigation.ts create mode 100644 apps/web/src/settings/build-settings-routes.test.tsx create mode 100644 apps/web/src/settings/build-settings-routes.tsx create mode 100644 apps/web/src/settings/index.ts create mode 100644 apps/web/src/settings/settings-index-page.tsx create mode 100644 apps/web/src/settings/settings-layout.tsx create mode 100644 apps/web/src/settings/settings-navigation.test.tsx create mode 100644 apps/web/src/settings/settings-navigation.tsx create mode 100644 apps/web/src/settings/settings-section-catalog.ts create mode 100644 docs/customer-invoices/settings/frontend-erp-settings-verifactu-design.md create mode 100644 docs/customer-invoices/settings/verifactu-company-profile-backend-design.md create mode 100644 docs/customer-invoices/sql/add-company-verifactu-profiles.sql create mode 100644 docs/customer-invoices/sql/add-issued-invoice-verifactu-snapshots.sql create mode 100644 modules/customer-invoices/src/api/application/company-verifactu-profile/di/company-verifactu-profile-use-cases.di.ts create mode 100644 modules/customer-invoices/src/api/application/company-verifactu-profile/di/index.ts create mode 100644 modules/customer-invoices/src/api/application/company-verifactu-profile/index.ts create mode 100644 modules/customer-invoices/src/api/application/company-verifactu-profile/repositories/company-verifactu-profile.repository.interface.ts create mode 100644 modules/customer-invoices/src/api/application/company-verifactu-profile/repositories/index.ts create mode 100644 modules/customer-invoices/src/api/application/company-verifactu-profile/services/company-verifactu-profile-response-builder.ts create mode 100644 modules/customer-invoices/src/api/application/company-verifactu-profile/services/index.ts create mode 100644 modules/customer-invoices/src/api/application/company-verifactu-profile/use-cases/company-verifactu-profile.use-cases.test.ts create mode 100644 modules/customer-invoices/src/api/application/company-verifactu-profile/use-cases/get-company-verifactu-profile.use-case.ts create mode 100644 modules/customer-invoices/src/api/application/company-verifactu-profile/use-cases/index.ts create mode 100644 modules/customer-invoices/src/api/application/company-verifactu-profile/use-cases/update-company-verifactu-profile.use-case.ts create mode 100644 modules/customer-invoices/src/api/application/issued-invoices/services/resolve-proforma-issue-verifactu-snapshots.test.ts create mode 100644 modules/customer-invoices/src/api/application/issued-invoices/services/resolve-proforma-issue-verifactu-snapshots.ts create mode 100644 modules/customer-invoices/src/api/domain/company-verifactu-profile/company-verifactu-profile.aggregate.test.ts create mode 100644 modules/customer-invoices/src/api/domain/company-verifactu-profile/company-verifactu-profile.aggregate.ts create mode 100644 modules/customer-invoices/src/api/domain/company-verifactu-profile/company-verifactu-profile.types.ts create mode 100644 modules/customer-invoices/src/api/domain/company-verifactu-profile/index.ts create mode 100644 modules/customer-invoices/src/api/domain/issued-invoices/value-objects/issued-invoice-fiscal-classification.vo.ts create mode 100644 modules/customer-invoices/src/api/domain/issued-invoices/value-objects/issued-invoice-verifactu-policy.vo.test.ts create mode 100644 modules/customer-invoices/src/api/domain/issued-invoices/value-objects/issued-invoice-verifactu-policy.vo.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/company-verifactu-profile.model.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/di/company-verifactu-profile.di.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/di/index.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/express/company-verifactu-profile.routes.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/express/controllers/company-verifactu-profile.controllers.test.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/express/controllers/get-company-verifactu-profile.controller.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/express/controllers/index.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/express/controllers/update-company-verifactu-profile.controller.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/express/index.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/index.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/index.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/sequelize/index.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/sequelize/mappers/company-verifactu-profile-domain.mapper.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/sequelize/mappers/index.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/sequelize/repositories/company-verifactu-profile.repository.test.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/sequelize/repositories/company-verifactu-profile.repository.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/sequelize/repositories/index.ts create mode 100644 modules/customer-invoices/src/common/dto/request/settings/index.ts create mode 100644 modules/customer-invoices/src/common/dto/request/settings/update-company-verifactu-profile.request.dto.ts create mode 100644 modules/customer-invoices/src/common/dto/response/settings/company-verifactu-profile.response.dto.ts create mode 100644 modules/customer-invoices/src/common/dto/response/settings/index.ts create mode 100644 modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-fiscal-classification.dto.ts create mode 100644 modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-verifactu-policy.dto.ts create mode 100644 modules/customer-invoices/src/web/manifest.test.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/controllers/index.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/controllers/use-company-verifactu-profile.controller.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/controllers/use-company-verifactu-settings-page-controller.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/entities/company-verifactu-profile-form.entity.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/entities/company-verifactu-profile-form.schema.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/entities/company-verifactu-settings-page-error-view-model.entity.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/entities/index.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/index.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/shared/adapters/get-company-verifactu-profile.adapter.test.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/shared/adapters/get-company-verifactu-profile.adapter.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/shared/adapters/index.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/shared/adapters/update-company-verifactu-profile.adapter.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/shared/api/get-company-verifactu-profile.api.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/shared/api/index.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/shared/api/update-company-verifactu-profile.api.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/shared/entities/company-verifactu-profile.entity.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/shared/entities/index.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/shared/hooks/index.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/shared/hooks/keys.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/shared/hooks/to-validation-errors.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/shared/hooks/use-company-verifactu-profile-query.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/shared/hooks/use-company-verifactu-profile-update-mutation.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/shared/index.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/ui/components/company-verifactu-settings-skeleton.tsx create mode 100644 modules/customer-invoices/src/web/settings/verifactu/ui/components/index.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/ui/index.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/ui/pages/company-verifactu-settings-page.tsx create mode 100644 modules/customer-invoices/src/web/settings/verifactu/ui/pages/index.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/ui/sections/company-verifactu-configuration-section.tsx create mode 100644 modules/customer-invoices/src/web/settings/verifactu/ui/sections/company-verifactu-defaults-section.tsx create mode 100644 modules/customer-invoices/src/web/settings/verifactu/ui/sections/company-verifactu-status-section.tsx create mode 100644 modules/customer-invoices/src/web/settings/verifactu/ui/sections/index.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/utils/build-company-verifactu-profile-form.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/utils/build-company-verifactu-settings-page-error-view-model.test.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/utils/build-company-verifactu-settings-page-error-view-model.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/utils/build-update-company-verifactu-profile-params.test.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/utils/build-update-company-verifactu-profile-params.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/utils/index.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/utils/resolve-company-verifactu-settings-page-feedback-state.test.ts create mode 100644 modules/customer-invoices/src/web/settings/verifactu/utils/resolve-company-verifactu-settings-page-feedback-state.ts diff --git a/apps/web/src/components/module-routes.tsx b/apps/web/src/components/module-routes.tsx index 4c8ea0cc..a8a79aa5 100644 --- a/apps/web/src/components/module-routes.tsx +++ b/apps/web/src/components/module-routes.tsx @@ -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); } } } diff --git a/apps/web/src/layout/app-sidebar-settings-menu.tsx b/apps/web/src/layout/app-sidebar-settings-menu.tsx index 27d69aba..b0df491e 100644 --- a/apps/web/src/layout/app-sidebar-settings-menu.tsx +++ b/apps/web/src/layout/app-sidebar-settings-menu.tsx @@ -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 - {settingsItems.map((item) => { + {appSidebarSettingsItems.map((item) => { const Icon = item.icon; return ( diff --git a/apps/web/src/layout/app-sidebar.config.ts b/apps/web/src/layout/app-sidebar.config.ts index bec4c2f3..65965432 100644 --- a/apps/web/src/layout/app-sidebar.config.ts +++ b/apps/web/src/layout/app-sidebar.config.ts @@ -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 }, ]; diff --git a/apps/web/src/locales/en.json b/apps/web/src/locales/en.json index c22d1c7f..32958d28 100644 --- a/apps/web/src/locales/en.json +++ b/apps/web/src/locales/en.json @@ -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." + } } } } diff --git a/apps/web/src/locales/es.json b/apps/web/src/locales/es.json index 673e4160..69d4e101 100644 --- a/apps/web/src/locales/es.json +++ b/apps/web/src/locales/es.json @@ -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." + } } } } diff --git a/apps/web/src/locales/i18n.ts b/apps/web/src/locales/i18n.ts index ac6b5913..ced59d79 100644 --- a/apps/web/src/locales/i18n.ts +++ b/apps/web/src/locales/i18n.ts @@ -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: useEffect(() => { ensureAppTranslations(); diff --git a/apps/web/src/routes/app-routes.tsx b/apps/web/src/routes/app-routes.tsx index a9e569c0..e046f08b 100644 --- a/apps/web/src/routes/app-routes.tsx +++ b/apps/web/src/routes/app-routes.tsx @@ -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: , }, - { - path: "settings", - element: , - }, { path: "catalog", element: , @@ -131,6 +129,10 @@ export const getAppRouter = () => { path: "quotes", element: , }, + buildSettingsRoute({ + modules, + params, + }), ...developmentRoutes, ...appSidebarRoutes, diff --git a/apps/web/src/settings/build-settings-navigation.test.ts b/apps/web/src/settings/build-settings-navigation.test.ts new file mode 100644 index 00000000..30a1dcce --- /dev/null +++ b/apps/web/src/settings/build-settings-navigation.test.ts @@ -0,0 +1,191 @@ +/// + +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 => { + 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); +}); diff --git a/apps/web/src/settings/build-settings-navigation.ts b/apps/web/src/settings/build-settings-navigation.ts new file mode 100644 index 00000000..b06b4d0e --- /dev/null +++ b/apps/web/src/settings/build-settings-navigation.ts @@ -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(); + + 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); +}; diff --git a/apps/web/src/settings/build-settings-routes.test.tsx b/apps/web/src/settings/build-settings-routes.test.tsx new file mode 100644 index 00000000..c8636844 --- /dev/null +++ b/apps/web/src/settings/build-settings-routes.test.tsx @@ -0,0 +1,127 @@ +/// + +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( + + + + ); +}; + +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:
Contenido real de configuracion
, + }, + ], + }), + ], + }); + + 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/); +}); diff --git a/apps/web/src/settings/build-settings-routes.tsx b/apps/web/src/settings/build-settings-routes.tsx new file mode 100644 index 00000000..4add0163 --- /dev/null +++ b/apps/web/src/settings/build-settings-routes.tsx @@ -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 ; + } + + return ; +}; + +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 = ; + const settingsElement = protectRoute ? ( + + {settingsLayout} + + ) : ( + settingsLayout + ); + + return { + path: "settings", + element: settingsElement, + children: [ + { + index: true, + element: ( + + ), + }, + ...settingsChildRoutes, + { + path: "*", + element: , + }, + ], + }; +}; diff --git a/apps/web/src/settings/index.ts b/apps/web/src/settings/index.ts new file mode 100644 index 00000000..2affbe84 --- /dev/null +++ b/apps/web/src/settings/index.ts @@ -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"; diff --git a/apps/web/src/settings/settings-index-page.tsx b/apps/web/src/settings/settings-index-page.tsx new file mode 100644 index 00000000..78aec452 --- /dev/null +++ b/apps/web/src/settings/settings-index-page.tsx @@ -0,0 +1,16 @@ +import { useAppTranslation } from "@/locales/i18n"; + +export const SettingsIndexPage = () => { + const { t } = useAppTranslation(); + + return ( +
+
+

{t("web.settings.empty.title")}

+

+ {t("web.settings.empty.description")} +

+
+
+ ); +}; diff --git a/apps/web/src/settings/settings-layout.tsx b/apps/web/src/settings/settings-layout.tsx new file mode 100644 index 00000000..ecaee1e6 --- /dev/null +++ b/apps/web/src/settings/settings-layout.tsx @@ -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 ( + + + + +
+ + +
+ +
+
+
+
+ ); +}; diff --git a/apps/web/src/settings/settings-navigation.test.tsx b/apps/web/src/settings/settings-navigation.test.tsx new file mode 100644 index 00000000..48c21229 --- /dev/null +++ b/apps/web/src/settings/settings-navigation.test.tsx @@ -0,0 +1,121 @@ +/// + +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( + + + + + + ); +}; + +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, /]*aria-label="Configuración"/); + assert.match(html, />EmpresaFacturaciónDatos de empresaVeriFactu { + 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, /
    { + return i18n.t(labelKey, { + defaultValue: labelKey, + ns: moduleName, + }); +}; + +export const SettingsNavigation = ({ sections }: SettingsNavigationProps) => { + const { t } = useAppTranslation(); + + if (sections.length === 0) { + return ( +

    + {t("web.settings.empty.navigation")} +

    + ); + } + + return ( + + ); +}; diff --git a/apps/web/src/settings/settings-section-catalog.ts b/apps/web/src/settings/settings-section-catalog.ts new file mode 100644 index 00000000..5067efed --- /dev/null +++ b/apps/web/src/settings/settings-section-catalog.ts @@ -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", + }, +]; diff --git a/docs/README.md b/docs/README.md index 43a2ff6a..ba3a51b3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) diff --git a/docs/customer-invoices/README.md b/docs/customer-invoices/README.md index 0c7ddc04..a9bba0f2 100644 --- a/docs/customer-invoices/README.md +++ b/docs/customer-invoices/README.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) diff --git a/docs/customer-invoices/settings/frontend-erp-settings-verifactu-design.md b/docs/customer-invoices/settings/frontend-erp-settings-verifactu-design.md new file mode 100644 index 00000000..f8315b1c --- /dev/null +++ b/docs/customer-invoices/settings/frontend-erp-settings-verifactu-design.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. diff --git a/docs/customer-invoices/settings/verifactu-company-profile-backend-design.md b/docs/customer-invoices/settings/verifactu-company-profile-backend-design.md new file mode 100644 index 00000000..1cfbbad2 --- /dev/null +++ b/docs/customer-invoices/settings/verifactu-company-profile-backend-design.md @@ -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 { + public static create( + props: CompanyVerifactuProfileProps, + ): Result; + + public reconfigure( + props: CompanyVerifactuProfileReconfiguration, + ): Result; +} +``` + +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 +``` diff --git a/docs/customer-invoices/sql/README.md b/docs/customer-invoices/sql/README.md index f522c464..03b054c6 100644 --- a/docs/customer-invoices/sql/README.md +++ b/docs/customer-invoices/sql/README.md @@ -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 diff --git a/docs/customer-invoices/sql/add-company-verifactu-profiles.sql b/docs/customer-invoices/sql/add-company-verifactu-profiles.sql new file mode 100644 index 00000000..3d84e065 --- /dev/null +++ b/docs/customer-invoices/sql/add-company-verifactu-profiles.sql @@ -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; diff --git a/docs/customer-invoices/sql/add-issued-invoice-verifactu-snapshots.sql b/docs/customer-invoices/sql/add-issued-invoice-verifactu-snapshots.sql new file mode 100644 index 00000000..c3eaadb6 --- /dev/null +++ b/docs/customer-invoices/sql/add-issued-invoice-verifactu-snapshots.sql @@ -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' +-- ); diff --git a/docs/settings/frontend-erp-settings-module-integration-design.md b/docs/settings/frontend-erp-settings-module-integration-design.md index 47d39c13..0101cbf8 100644 --- a/docs/settings/frontend-erp-settings-module-integration-design.md +++ b/docs/settings/frontend-erp-settings-module-integration-design.md @@ -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 diff --git a/docs/settings/frontend-erp-settings-system-design.md b/docs/settings/frontend-erp-settings-system-design.md index c9996feb..dc7b799a 100644 --- a/docs/settings/frontend-erp-settings-system-design.md +++ b/docs/settings/frontend-erp-settings-system-design.md @@ -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. diff --git a/modules/core/src/web/lib/modules/module-client.interface.ts b/modules/core/src/web/lib/modules/module-client.interface.ts index 5faad328..0d11aacc 100644 --- a/modules/core/src/web/lib/modules/module-client.interface.ts +++ b/modules/core/src/web/lib/modules/module-client.interface.ts @@ -7,6 +7,17 @@ export type ModuleClientParams = Record; 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[]; } diff --git a/modules/customer-invoices/src/api/application/company-verifactu-profile/di/company-verifactu-profile-use-cases.di.ts b/modules/customer-invoices/src/api/application/company-verifactu-profile/di/company-verifactu-profile-use-cases.di.ts new file mode 100644 index 00000000..e291ca92 --- /dev/null +++ b/modules/customer-invoices/src/api/application/company-verifactu-profile/di/company-verifactu-profile-use-cases.di.ts @@ -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, + }); +} diff --git a/modules/customer-invoices/src/api/application/company-verifactu-profile/di/index.ts b/modules/customer-invoices/src/api/application/company-verifactu-profile/di/index.ts new file mode 100644 index 00000000..5cd3b02e --- /dev/null +++ b/modules/customer-invoices/src/api/application/company-verifactu-profile/di/index.ts @@ -0,0 +1 @@ +export * from "./company-verifactu-profile-use-cases.di"; diff --git a/modules/customer-invoices/src/api/application/company-verifactu-profile/index.ts b/modules/customer-invoices/src/api/application/company-verifactu-profile/index.ts new file mode 100644 index 00000000..ce9f4ac2 --- /dev/null +++ b/modules/customer-invoices/src/api/application/company-verifactu-profile/index.ts @@ -0,0 +1,4 @@ +export * from "./di"; +export * from "./repositories"; +export * from "./services"; +export * from "./use-cases"; diff --git a/modules/customer-invoices/src/api/application/company-verifactu-profile/repositories/company-verifactu-profile.repository.interface.ts b/modules/customer-invoices/src/api/application/company-verifactu-profile/repositories/company-verifactu-profile.repository.interface.ts new file mode 100644 index 00000000..2ce522ff --- /dev/null +++ b/modules/customer-invoices/src/api/application/company-verifactu-profile/repositories/company-verifactu-profile.repository.interface.ts @@ -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, Error>>; + + create(profile: CompanyVerifactuProfile, transaction?: unknown): Promise>; + + update(profile: CompanyVerifactuProfile, transaction?: unknown): Promise>; +} diff --git a/modules/customer-invoices/src/api/application/company-verifactu-profile/repositories/index.ts b/modules/customer-invoices/src/api/application/company-verifactu-profile/repositories/index.ts new file mode 100644 index 00000000..60df1d2f --- /dev/null +++ b/modules/customer-invoices/src/api/application/company-verifactu-profile/repositories/index.ts @@ -0,0 +1 @@ +export * from "./company-verifactu-profile.repository.interface"; diff --git a/modules/customer-invoices/src/api/application/company-verifactu-profile/services/company-verifactu-profile-response-builder.ts b/modules/customer-invoices/src/api/application/company-verifactu-profile/services/company-verifactu-profile-response-builder.ts new file mode 100644 index 00000000..418e41de --- /dev/null +++ b/modules/customer-invoices/src/api/application/company-verifactu-profile/services/company-verifactu-profile-response-builder.ts @@ -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): CompanyVerifactuProfileResponseDTO; +} + +export class CompanyVerifactuProfileResponseBuilder + implements ICompanyVerifactuProfileResponseBuilder +{ + public build(profile: Maybe): 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(), + }; + } +} diff --git a/modules/customer-invoices/src/api/application/company-verifactu-profile/services/index.ts b/modules/customer-invoices/src/api/application/company-verifactu-profile/services/index.ts new file mode 100644 index 00000000..249a5fa9 --- /dev/null +++ b/modules/customer-invoices/src/api/application/company-verifactu-profile/services/index.ts @@ -0,0 +1 @@ +export * from "./company-verifactu-profile-response-builder"; diff --git a/modules/customer-invoices/src/api/application/company-verifactu-profile/use-cases/company-verifactu-profile.use-cases.test.ts b/modules/customer-invoices/src/api/application/company-verifactu-profile/use-cases/company-verifactu-profile.use-cases.test.ts new file mode 100644 index 00000000..72bf6c4a --- /dev/null +++ b/modules/customer-invoices/src/api/application/company-verifactu-profile/use-cases/company-verifactu-profile.use-cases.test.ts @@ -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 (work: (transaction: unknown) => Promise) => 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); +}); diff --git a/modules/customer-invoices/src/api/application/company-verifactu-profile/use-cases/get-company-verifactu-profile.use-case.ts b/modules/customer-invoices/src/api/application/company-verifactu-profile/use-cases/get-company-verifactu-profile.use-case.ts new file mode 100644 index 00000000..6edd9b3b --- /dev/null +++ b/modules/customer-invoices/src/api/application/company-verifactu-profile/use-cases/get-company-verifactu-profile.use-case.ts @@ -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> { + 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)); + }); + } +} diff --git a/modules/customer-invoices/src/api/application/company-verifactu-profile/use-cases/index.ts b/modules/customer-invoices/src/api/application/company-verifactu-profile/use-cases/index.ts new file mode 100644 index 00000000..5daecd75 --- /dev/null +++ b/modules/customer-invoices/src/api/application/company-verifactu-profile/use-cases/index.ts @@ -0,0 +1,2 @@ +export * from "./get-company-verifactu-profile.use-case"; +export * from "./update-company-verifactu-profile.use-case"; diff --git a/modules/customer-invoices/src/api/application/company-verifactu-profile/use-cases/update-company-verifactu-profile.use-case.ts b/modules/customer-invoices/src/api/application/company-verifactu-profile/use-cases/update-company-verifactu-profile.use-case.ts new file mode 100644 index 00000000..fabca13b --- /dev/null +++ b/modules/customer-invoices/src/api/application/company-verifactu-profile/use-cases/update-company-verifactu-profile.use-case.ts @@ -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> { + 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() + : Maybe.some(dto.exemption_reason_code); + const exemptionReasonDetail = + dto.exemption_reason_detail == null + ? Maybe.none() + : Maybe.some(dto.exemption_reason_detail); + const verifactuEffectiveAt = + dto.verifactu_effective_at == null + ? Maybe.none() + : Maybe.some(new Date(dto.verifactu_effective_at)); + const autoRenewMode = + dto.auto_renew_mode == null ? Maybe.none() : Maybe.some(dto.auto_renew_mode); + const taxRegimeCode = + dto.defaults.tax_regime_code == null + ? Maybe.none() + : Maybe.some(dto.defaults.tax_regime_code); + const operationQualificationCode = + dto.defaults.operation_qualification_code == null + ? Maybe.none() + : Maybe.some(dto.defaults.operation_qualification_code); + const defaultExemptionReasonCode = + dto.defaults.exemption_reason_code == null + ? Maybe.none() + : Maybe.some(dto.defaults.exemption_reason_code); + + return { + profileStatus: dto.profile_status, + exemptionReasonCode, + exemptionReasonDetail, + verifactuEffectiveAt, + autoRenewMode, + defaults: { + taxRegimeCode, + operationQualificationCode, + exemptionReasonCode: defaultExemptionReasonCode, + }, + }; + } +} diff --git a/modules/customer-invoices/src/api/application/index.ts b/modules/customer-invoices/src/api/application/index.ts index e4a0d309..52599ce7 100644 --- a/modules/customer-invoices/src/api/application/index.ts +++ b/modules/customer-invoices/src/api/application/index.ts @@ -1,3 +1,4 @@ +export * from "./company-verifactu-profile"; export * from "./invoice-series"; export * from "./issued-invoices"; export * from "./proformas"; diff --git a/modules/customer-invoices/src/api/application/issued-invoices/models/proforma-issue-read.model.ts b/modules/customer-invoices/src/api/application/issued-invoices/models/proforma-issue-read.model.ts index 2f97edce..88a1371c 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/models/proforma-issue-read.model.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/models/proforma-issue-read.model.ts @@ -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; + }; } diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/index.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/index.ts index 59f1c11c..02cc60ec 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/services/index.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/services/index.ts @@ -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"; diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts index 46da5173..ce349d34 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts @@ -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 { + 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 { + let effectiveAt = Maybe.none(); + + 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, + }); + } } diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/resolve-proforma-issue-verifactu-snapshots.test.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/resolve-proforma-issue-verifactu-snapshots.test.ts new file mode 100644 index 00000000..1f93c823 --- /dev/null +++ b/modules/customer-invoices/src/api/application/issued-invoices/services/resolve-proforma-issue-verifactu-snapshots.test.ts @@ -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); +}); diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/resolve-proforma-issue-verifactu-snapshots.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/resolve-proforma-issue-verifactu-snapshots.ts new file mode 100644 index 00000000..0e1ae216 --- /dev/null +++ b/modules/customer-invoices/src/api/application/issued-invoices/services/resolve-proforma-issue-verifactu-snapshots.ts @@ -0,0 +1,69 @@ +import { Maybe } from "@repo/rdx-utils"; + +import type { CompanyVerifactuProfile } from "../../../domain"; +import { CompanyVerifactuProfileStatus } from "../../../domain"; + +export interface ResolveProformaIssueVerifactuSnapshotsInput { + proformaTaxRegimeCode: Maybe; + customerTaxRegimeCode: Maybe; + companyProfile: CompanyVerifactuProfile | null; +} + +export interface ResolveProformaIssueVerifactuSnapshotsOutput { + taxRegimeCode: Maybe; + fiscalClassification: { + operationQualificationCode: Maybe; + exemptionReasonCode: Maybe; + }; + verifactuPolicy: { + profileStatus: CompanyVerifactuProfileStatus; + usesVerifactu: boolean; + effectiveAt: Maybe; + }; +} + +/** + * 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(); + + const operationQualificationCode = + companyProfile?.defaults.operationQualificationCode ?? Maybe.none(); + const exemptionReasonCode = + companyProfile?.defaults.exemptionReasonCode ?? Maybe.none(); + + const profileStatus = companyProfile?.profileStatus ?? CompanyVerifactuProfileStatus.NotConfigured; + const usesVerifactu = + profileStatus === CompanyVerifactuProfileStatus.ObligatedWithVerifactu; + const effectiveAt = + usesVerifactu && companyProfile?.verifactuEffectiveAt.isSome() + ? companyProfile.verifactuEffectiveAt + : Maybe.none(); + + return { + taxRegimeCode, + fiscalClassification: { + operationQualificationCode, + exemptionReasonCode, + }, + verifactuPolicy: { + profileStatus, + usesVerifactu, + effectiveAt, + }, + }; +} diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot-builder.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot-builder.ts index afb973ea..4278749d 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot-builder.ts @@ -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(), diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot.interface.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot.interface.ts index 4aec0ffd..5a793986 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot.interface.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot.interface.ts @@ -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[]; diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-deleter.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-deleter.di.ts index 473a5614..2c05717c 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-deleter.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-deleter.di.ts @@ -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({ diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-issuer.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-issuer.di.ts index 28f1a34c..57d6c887 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-issuer.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-issuer.di.ts @@ -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, }); }; diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts index fdfd0df3..de8fa3c5 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts @@ -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, diff --git a/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-issue-read-model.assembler.ts b/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-issue-read-model.assembler.ts index 5833cb8b..86943e3c 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-issue-read-model.assembler.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-issue-read-model.assembler.ts @@ -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; 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, diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/issue/issue-proforma-response.snapshot-builder.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/issue/issue-proforma-response.snapshot-builder.ts index 0a156119..4e5eec66 100644 --- a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/issue/issue-proforma-response.snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/issue/issue-proforma-response.snapshot-builder.ts @@ -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(), diff --git a/modules/customer-invoices/src/api/domain/company-verifactu-profile/company-verifactu-profile.aggregate.test.ts b/modules/customer-invoices/src/api/domain/company-verifactu-profile/company-verifactu-profile.aggregate.test.ts new file mode 100644 index 00000000..66ac39f3 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/company-verifactu-profile/company-verifactu-profile.aggregate.test.ts @@ -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 { + 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); +}); diff --git a/modules/customer-invoices/src/api/domain/company-verifactu-profile/company-verifactu-profile.aggregate.ts b/modules/customer-invoices/src/api/domain/company-verifactu-profile/company-verifactu-profile.aggregate.ts new file mode 100644 index 00000000..c51a0563 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/company-verifactu-profile/company-verifactu-profile.aggregate.ts @@ -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; + exemptionReasonDetail: Maybe; + verifactuEffectiveAt: Maybe; + autoRenewMode: Maybe; + defaults: CompanyVerifactuDefaults; + createdAt: Date; + updatedAt: Date; +} + +type CompanyVerifactuProfileInternalProps = CompanyVerifactuProfileProps; + +export class CompanyVerifactuProfile extends AggregateRoot { + protected constructor(props: CompanyVerifactuProfileInternalProps, id?: UniqueID) { + super(props, id); + } + + public static create( + props: CompanyVerifactuProfileProps, + id?: UniqueID + ): Result { + 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 { + 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 { + 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 { + return this.props.exemptionReasonCode; + } + + public get exemptionReasonDetail(): Maybe { + return this.props.exemptionReasonDetail; + } + + public get verifactuEffectiveAt(): Maybe { + return this.props.verifactuEffectiveAt; + } + + public get autoRenewMode(): Maybe { + 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(), + }; + } +} diff --git a/modules/customer-invoices/src/api/domain/company-verifactu-profile/company-verifactu-profile.types.ts b/modules/customer-invoices/src/api/domain/company-verifactu-profile/company-verifactu-profile.types.ts new file mode 100644 index 00000000..0d42ee6c --- /dev/null +++ b/modules/customer-invoices/src/api/domain/company-verifactu-profile/company-verifactu-profile.types.ts @@ -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; + operationQualificationCode: Maybe; + exemptionReasonCode: Maybe; +} + +export interface CompanyVerifactuProfileReconfiguration { + profileStatus: CompanyVerifactuProfileStatus; + exemptionReasonCode: Maybe; + exemptionReasonDetail: Maybe; + verifactuEffectiveAt: Maybe; + autoRenewMode: Maybe; + defaults: CompanyVerifactuDefaults; +} diff --git a/modules/customer-invoices/src/api/domain/company-verifactu-profile/index.ts b/modules/customer-invoices/src/api/domain/company-verifactu-profile/index.ts new file mode 100644 index 00000000..7b26351b --- /dev/null +++ b/modules/customer-invoices/src/api/domain/company-verifactu-profile/index.ts @@ -0,0 +1,2 @@ +export * from "./company-verifactu-profile.aggregate"; +export * from "./company-verifactu-profile.types"; diff --git a/modules/customer-invoices/src/api/domain/index.ts b/modules/customer-invoices/src/api/domain/index.ts index 90501b87..828b821f 100644 --- a/modules/customer-invoices/src/api/domain/index.ts +++ b/modules/customer-invoices/src/api/domain/index.ts @@ -1,4 +1,5 @@ export * from "./common"; +export * from "./company-verifactu-profile"; export * from "./invoice-series"; export * from "./issued-invoices"; export * from "./proformas"; diff --git a/modules/customer-invoices/src/api/domain/issued-invoices/aggregates/issued-invoice.aggregate.ts b/modules/customer-invoices/src/api/domain/issued-invoices/aggregates/issued-invoice.aggregate.ts index 652c0743..5f9b0538 100644 --- a/modules/customer-invoices/src/api/domain/issued-invoices/aggregates/issued-invoice.aggregate.ts +++ b/modules/customer-invoices/src/api/domain/issued-invoices/aggregates/issued-invoice.aggregate.ts @@ -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; + verifactuPolicy: Maybe; + verifactu: Maybe; } @@ -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 { + return this.props.fiscalClassification; + } + + public get verifactuPolicy(): Maybe { + return this.props.verifactuPolicy; + } + public get subtotalAmount(): InvoiceAmount { return this.props.subtotalAmount; } diff --git a/modules/customer-invoices/src/api/domain/issued-invoices/value-objects/index.ts b/modules/customer-invoices/src/api/domain/issued-invoices/value-objects/index.ts index 4e79622a..3442626f 100644 --- a/modules/customer-invoices/src/api/domain/issued-invoices/value-objects/index.ts +++ b/modules/customer-invoices/src/api/domain/issued-invoices/value-objects/index.ts @@ -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"; diff --git a/modules/customer-invoices/src/api/domain/issued-invoices/value-objects/issued-invoice-fiscal-classification.vo.ts b/modules/customer-invoices/src/api/domain/issued-invoices/value-objects/issued-invoice-fiscal-classification.vo.ts new file mode 100644 index 00000000..f43a764a --- /dev/null +++ b/modules/customer-invoices/src/api/domain/issued-invoices/value-objects/issued-invoice-fiscal-classification.vo.ts @@ -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; + operationQualificationCode: Maybe; + exemptionReasonCode: Maybe; +}; + +export class IssuedInvoiceFiscalClassification extends ValueObject { + 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 { + 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 { + return this.props.taxRegimeLabel; + } + + public get operationQualificationCode(): Maybe { + return this.props.operationQualificationCode; + } + + public get exemptionReasonCode(): Maybe { + 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, + }; + } +} diff --git a/modules/customer-invoices/src/api/domain/issued-invoices/value-objects/issued-invoice-verifactu-policy.vo.test.ts b/modules/customer-invoices/src/api/domain/issued-invoices/value-objects/issued-invoice-verifactu-policy.vo.test.ts new file mode 100644 index 00000000..429a03b7 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/issued-invoices/value-objects/issued-invoice-verifactu-policy.vo.test.ts @@ -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", + }); +}); diff --git a/modules/customer-invoices/src/api/domain/issued-invoices/value-objects/issued-invoice-verifactu-policy.vo.ts b/modules/customer-invoices/src/api/domain/issued-invoices/value-objects/issued-invoice-verifactu-policy.vo.ts new file mode 100644 index 00000000..54f16e59 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/issued-invoices/value-objects/issued-invoice-verifactu-policy.vo.ts @@ -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; +}; + +export class IssuedInvoiceVerifactuPolicy extends ValueObject { + 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 { + 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 { + 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()), + }; + } +} diff --git a/modules/customer-invoices/src/api/index.ts b/modules/customer-invoices/src/api/index.ts index ea8915eb..c62aed65 100644 --- a/modules/customer-invoices/src/api/index.ts +++ b/modules/customer-invoices/src/api/index.ts @@ -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); diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/index.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/index.ts index 69dbc515..0fd6f066 100644 --- a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/index.ts @@ -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, diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/company-verifactu-profile.model.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/company-verifactu-profile.model.ts new file mode 100644 index 00000000..5c8fd809 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/company-verifactu-profile.model.ts @@ -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, + InferCreationAttributes +> { + 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; +}; diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/index.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/index.ts index 14253ace..ed9d41fd 100644 --- a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/index.ts @@ -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"; diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-issued-invoice.model.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-issued-invoice.model.ts index 571086ab..9e510544 100644 --- a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-issued-invoice.model.ts +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-issued-invoice.model.ts @@ -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; + declare exemption_reason_code: CreationOptional; + declare verifactu_profile_status: CreationOptional; + declare uses_verifactu: CreationOptional; + declare verifactu_effective_at: CreationOptional; // 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, diff --git a/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/di/company-verifactu-profile.di.ts b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/di/company-verifactu-profile.di.ts new file mode 100644 index 00000000..582b41d2 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/di/company-verifactu-profile.di.ts @@ -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(), + }), + }, + }; +} diff --git a/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/di/index.ts b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/di/index.ts new file mode 100644 index 00000000..596c0933 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/di/index.ts @@ -0,0 +1 @@ +export * from "./company-verifactu-profile.di"; diff --git a/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/express/company-verifactu-profile.routes.ts b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/express/company-verifactu-profile.routes.ts new file mode 100644 index 00000000..56a11ca6 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/express/company-verifactu-profile.routes.ts @@ -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( + "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); +}; diff --git a/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/express/controllers/company-verifactu-profile.controllers.test.ts b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/express/controllers/company-verifactu-profile.controllers.test.ts new file mode 100644 index 00000000..9fa6e83b --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/express/controllers/company-verifactu-profile.controllers.test.ts @@ -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, + 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) { + Object.assign(this.headers, values); + return this; + }, + send(payload?: unknown) { + this.body = payload; + return this; + }, + }; +} + +function createRequestDouble(overrides: Partial = {}): 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); + + await controller.execute(req, res as never as Response, (() => undefined) as NextFunction); + + assert.equal(res.statusCode, 403); +}); diff --git a/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/express/controllers/get-company-verifactu-profile.controller.ts b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/express/controllers/get-company-verifactu-profile.controller.ts new file mode 100644 index 00000000..14c7280c --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/express/controllers/get-company-verifactu-profile.controller.ts @@ -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) + ); + } +} diff --git a/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/express/controllers/index.ts b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/express/controllers/index.ts new file mode 100644 index 00000000..2e4673b9 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/express/controllers/index.ts @@ -0,0 +1,2 @@ +export * from "./get-company-verifactu-profile.controller"; +export * from "./update-company-verifactu-profile.controller"; diff --git a/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/express/controllers/update-company-verifactu-profile.controller.ts b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/express/controllers/update-company-verifactu-profile.controller.ts new file mode 100644 index 00000000..5a694605 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/express/controllers/update-company-verifactu-profile.controller.ts @@ -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) + ); + } +} diff --git a/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/express/index.ts b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/express/index.ts new file mode 100644 index 00000000..5340ff57 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/express/index.ts @@ -0,0 +1,2 @@ +export * from "./company-verifactu-profile.routes"; +export * from "./controllers"; diff --git a/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/index.ts b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/index.ts new file mode 100644 index 00000000..3e34d105 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/index.ts @@ -0,0 +1,3 @@ +export * from "./di"; +export * from "./express"; +export * from "./persistence"; diff --git a/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/index.ts b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/index.ts new file mode 100644 index 00000000..62f8ac11 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/index.ts @@ -0,0 +1 @@ +export * from "./sequelize"; diff --git a/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/sequelize/index.ts b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/sequelize/index.ts new file mode 100644 index 00000000..bad13e0e --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/sequelize/index.ts @@ -0,0 +1,2 @@ +export * from "./mappers"; +export * from "./repositories"; diff --git a/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/sequelize/mappers/company-verifactu-profile-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/sequelize/mappers/company-verifactu-profile-domain.mapper.ts new file mode 100644 index 00000000..032e6ca7 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/sequelize/mappers/company-verifactu-profile-domain.mapper.ts @@ -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 { + 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() + : Maybe.some(source.exemption_reason_code), + exemptionReasonDetail: + source.exemption_reason_detail == null + ? Maybe.none() + : Maybe.some(source.exemption_reason_detail), + verifactuEffectiveAt: + source.verifactu_effective_at == null + ? Maybe.none() + : Maybe.some(source.verifactu_effective_at), + autoRenewMode: + source.auto_renew_mode == null + ? Maybe.none() + : Maybe.some(source.auto_renew_mode as AutoRenewMode), + defaults: { + taxRegimeCode: + source.default_tax_regime_code == null + ? Maybe.none() + : Maybe.some(source.default_tax_regime_code), + operationQualificationCode: + source.default_operation_qualification_code == null + ? Maybe.none() + : Maybe.some(source.default_operation_qualification_code), + exemptionReasonCode: + source.default_exemption_reason_code == null + ? Maybe.none() + : 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 { + 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, + }); + } +} diff --git a/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/sequelize/mappers/index.ts b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/sequelize/mappers/index.ts new file mode 100644 index 00000000..03a24c46 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/sequelize/mappers/index.ts @@ -0,0 +1 @@ +export * from "./company-verifactu-profile-domain.mapper"; diff --git a/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/sequelize/repositories/company-verifactu-profile.repository.test.ts b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/sequelize/repositories/company-verifactu-profile.repository.test.ts new file mode 100644 index 00000000..fd52b644 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/sequelize/repositories/company-verifactu-profile.repository.test.ts @@ -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; + } +}); diff --git a/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/sequelize/repositories/company-verifactu-profile.repository.ts b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/sequelize/repositories/company-verifactu-profile.repository.ts new file mode 100644 index 00000000..e11157dd --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/sequelize/repositories/company-verifactu-profile.repository.ts @@ -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 + implements ICompanyVerifactuProfileRepository +{ + public constructor( + private readonly mapper: CompanyVerifactuProfileDomainMapper, + database: Sequelize + ) { + super({ database }); + } + + public async findByCompanyId( + companyId: UniqueID, + transaction?: unknown + ): Promise, 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> { + 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> { + 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)); + } + } +} diff --git a/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/sequelize/repositories/index.ts b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/sequelize/repositories/index.ts new file mode 100644 index 00000000..1626cac1 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/company-verifactu-profile/persistence/sequelize/repositories/index.ts @@ -0,0 +1 @@ +export * from "./company-verifactu-profile.repository"; diff --git a/modules/customer-invoices/src/api/infrastructure/index.ts b/modules/customer-invoices/src/api/infrastructure/index.ts index cece438d..c3e898aa 100644 --- a/modules/customer-invoices/src/api/infrastructure/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/index.ts @@ -1,3 +1,4 @@ export * from "./common"; +export * from "./company-verifactu-profile"; export * from "./issued-invoices"; export * from "./proformas"; diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-domain.mapper.ts index 0e4a2040..a1f98e3f 100644 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-domain.mapper.ts @@ -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(); + let verifactuPolicy = Maybe.none(); + + 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(), + }); + + 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); } } diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts index c53ca9b7..ebc95a9a 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts @@ -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({ diff --git a/modules/customer-invoices/src/common/dto/request/index.ts b/modules/customer-invoices/src/common/dto/request/index.ts index de2b09ca..f59eb142 100644 --- a/modules/customer-invoices/src/common/dto/request/index.ts +++ b/modules/customer-invoices/src/common/dto/request/index.ts @@ -1,2 +1,3 @@ export * from "./issued-invoices"; export * from "./proformas"; +export * from "./settings"; diff --git a/modules/customer-invoices/src/common/dto/request/settings/index.ts b/modules/customer-invoices/src/common/dto/request/settings/index.ts new file mode 100644 index 00000000..e16369ef --- /dev/null +++ b/modules/customer-invoices/src/common/dto/request/settings/index.ts @@ -0,0 +1 @@ +export * from "./update-company-verifactu-profile.request.dto"; diff --git a/modules/customer-invoices/src/common/dto/request/settings/update-company-verifactu-profile.request.dto.ts b/modules/customer-invoices/src/common/dto/request/settings/update-company-verifactu-profile.request.dto.ts new file mode 100644 index 00000000..88fbfe84 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/request/settings/update-company-verifactu-profile.request.dto.ts @@ -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 +>; diff --git a/modules/customer-invoices/src/common/dto/response/index.ts b/modules/customer-invoices/src/common/dto/response/index.ts index de2b09ca..f59eb142 100644 --- a/modules/customer-invoices/src/common/dto/response/index.ts +++ b/modules/customer-invoices/src/common/dto/response/index.ts @@ -1,2 +1,3 @@ export * from "./issued-invoices"; export * from "./proformas"; +export * from "./settings"; diff --git a/modules/customer-invoices/src/common/dto/response/issued-invoices/get-issued-invoice-by-id.response.dto.ts b/modules/customer-invoices/src/common/dto/response/issued-invoices/get-issued-invoice-by-id.response.dto.ts index 00728f78..6066dd63 100644 --- a/modules/customer-invoices/src/common/dto/response/issued-invoices/get-issued-invoice-by-id.response.dto.ts +++ b/modules/customer-invoices/src/common/dto/response/issued-invoices/get-issued-invoice-by-id.response.dto.ts @@ -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(), }); diff --git a/modules/customer-invoices/src/common/dto/response/proformas/issue-proforma-by-id.response.dto.ts b/modules/customer-invoices/src/common/dto/response/proformas/issue-proforma-by-id.response.dto.ts index 26450963..4e11cba6 100644 --- a/modules/customer-invoices/src/common/dto/response/proformas/issue-proforma-by-id.response.dto.ts +++ b/modules/customer-invoices/src/common/dto/response/proformas/issue-proforma-by-id.response.dto.ts @@ -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, }); diff --git a/modules/customer-invoices/src/common/dto/response/settings/company-verifactu-profile.response.dto.ts b/modules/customer-invoices/src/common/dto/response/settings/company-verifactu-profile.response.dto.ts new file mode 100644 index 00000000..66e1ee99 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/response/settings/company-verifactu-profile.response.dto.ts @@ -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 +>; diff --git a/modules/customer-invoices/src/common/dto/response/settings/index.ts b/modules/customer-invoices/src/common/dto/response/settings/index.ts new file mode 100644 index 00000000..a1a615bf --- /dev/null +++ b/modules/customer-invoices/src/common/dto/response/settings/index.ts @@ -0,0 +1 @@ +export * from "./company-verifactu-profile.response.dto"; diff --git a/modules/customer-invoices/src/common/dto/shared/issued-invoices/index.ts b/modules/customer-invoices/src/common/dto/shared/issued-invoices/index.ts index c7087b55..31b98c82 100644 --- a/modules/customer-invoices/src/common/dto/shared/issued-invoices/index.ts +++ b/modules/customer-invoices/src/common/dto/shared/issued-invoices/index.ts @@ -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"; diff --git a/modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-fiscal-classification.dto.ts b/modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-fiscal-classification.dto.ts new file mode 100644 index 00000000..3445af86 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-fiscal-classification.dto.ts @@ -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 +>; diff --git a/modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-verifactu-policy.dto.ts b/modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-verifactu-policy.dto.ts new file mode 100644 index 00000000..eab46fcf --- /dev/null +++ b/modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-verifactu-policy.dto.ts @@ -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 +>; diff --git a/modules/customer-invoices/src/common/locales/en.json b/modules/customer-invoices/src/common/locales/en.json index f1b892f5..3a4e778e 100644 --- a/modules/customer-invoices/src/common/locales/en.json +++ b/modules/customer-invoices/src/common/locales/en.json @@ -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." + } + } + } } } diff --git a/modules/customer-invoices/src/common/locales/es.json b/modules/customer-invoices/src/common/locales/es.json index 2a657f69..869b09fb 100644 --- a/modules/customer-invoices/src/common/locales/es.json +++ b/modules/customer-invoices/src/common/locales/es.json @@ -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." + } + } + } } } diff --git a/modules/customer-invoices/src/web/customer-invoice-routes.tsx b/modules/customer-invoices/src/web/customer-invoice-routes.tsx index 06c44750..f4400295 100644 --- a/modules/customer-invoices/src/web/customer-invoice-routes.tsx +++ b/modules/customer-invoices/src/web/customer-invoice-routes.tsx @@ -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[] ), }, + { + path: "settings/billing/verifactu", + handle: { + layout: "app-sidebar", + protected: true, + }, + element: , + }, ]; }; diff --git a/modules/customer-invoices/src/web/manifest.test.ts b/modules/customer-invoices/src/web/manifest.test.ts new file mode 100644 index 00000000..79cd5018 --- /dev/null +++ b/modules/customer-invoices/src/web/manifest.test.ts @@ -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); +}); diff --git a/modules/customer-invoices/src/web/manifest.ts b/modules/customer-invoices/src/web/manifest.ts index 3fff6dde..aec463b1 100644 --- a/modules/customer-invoices/src/web/manifest.ts +++ b/modules/customer-invoices/src/web/manifest.ts @@ -1,10 +1,13 @@ import type { IModuleClient, ModuleClientParams } from "@erp/core/client"; import { CustomerInvoiceRoutes } from "./customer-invoice-routes"; +import { ensureModuleTranslations } from "./i18n"; export const MODULE_NAME = "CustomerInvoices"; const MODULE_VERSION = "1.0.0"; +ensureModuleTranslations(); + export const CustomerInvoicesModuleManifest: IModuleClient = { name: MODULE_NAME, version: MODULE_VERSION, @@ -13,6 +16,17 @@ export const CustomerInvoicesModuleManifest: IModuleClient = { protected: true, // protegido por defecto layout: "app-sidebar", // layout por defecto + settings: { + entries: [ + { + id: "verifactu", + sectionId: "billing", + labelKey: "settings.verifactu.title", + path: "/settings/billing/verifactu", + }, + ], + }, + routes: (params: ModuleClientParams) => CustomerInvoiceRoutes(params), }; diff --git a/modules/customer-invoices/src/web/settings/verifactu/controllers/index.ts b/modules/customer-invoices/src/web/settings/verifactu/controllers/index.ts new file mode 100644 index 00000000..c1462a33 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/controllers/index.ts @@ -0,0 +1,2 @@ +export * from "./use-company-verifactu-profile.controller"; +export * from "./use-company-verifactu-settings-page-controller"; diff --git a/modules/customer-invoices/src/web/settings/verifactu/controllers/use-company-verifactu-profile.controller.ts b/modules/customer-invoices/src/web/settings/verifactu/controllers/use-company-verifactu-profile.controller.ts new file mode 100644 index 00000000..5af85c3a --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/controllers/use-company-verifactu-profile.controller.ts @@ -0,0 +1,238 @@ +import { applyValidationErrorCollection } from "@erp/core"; +import { formHasAnyDirty } from "@erp/core/client"; +import { useHookForm } from "@erp/core/hooks"; +import { getTaxRegimeOptions, useTaxRegimesListQuery } from "@erp/catalogs/client/tax-regimes"; +import { type ValidationErrorCollection, isValidationErrorCollection } from "@repo/rdx-ddd"; +import { + focusFirstInputFormError, + getFirstFormErrorMessage, + showErrorToast, + showSuccessToast, + showWarningToast, +} from "@repo/rdx-ui/helpers"; +import { useEffect, useId, useMemo, type SubmitEvent } from "react"; +import type { FieldErrors } from "react-hook-form"; + +import { useTranslation } from "../../../i18n"; +import { useCompanyVerifactuProfileUpdateMutation } from "../shared"; +import type { CompanyVerifactuProfile } from "../shared"; +import { + type CompanyVerifactuProfileForm, + CompanyVerifactuProfileFormSchema, +} from "../entities"; +import { + buildCompanyVerifactuProfileForm, + buildUpdateCompanyVerifactuProfileParams, +} from "../utils"; + +const normalizeSubmitError = (error: unknown): Error | ValidationErrorCollection => { + if (isValidationErrorCollection(error)) { + return error; + } + + if (error instanceof Error) { + return error; + } + + return new Error("Unknown error"); +}; + +const clearFieldsForStatus = ( + profileStatus: CompanyVerifactuProfileForm["profileStatus"], + form: ReturnType> +) => { + const currentValues = form.getValues(); + + if (profileStatus !== "exempt") { + if (currentValues.exemptionReasonCode !== null) { + form.setValue("exemptionReasonCode", null, { shouldDirty: true, shouldValidate: true }); + } + + if (currentValues.exemptionReasonDetail !== "") { + form.setValue("exemptionReasonDetail", "", { shouldDirty: true, shouldValidate: true }); + } + } + + if (profileStatus !== "obligated_with_verifactu") { + if (currentValues.verifactuEffectiveAt !== null) { + form.setValue("verifactuEffectiveAt", null, { shouldDirty: true, shouldValidate: true }); + } + + if (currentValues.autoRenewMode !== null) { + form.setValue("autoRenewMode", null, { shouldDirty: true, shouldValidate: true }); + } + } + + if (profileStatus === "not_configured") { + if (currentValues.defaults.taxRegimeCode !== null) { + form.setValue("defaults.taxRegimeCode", null, { shouldDirty: true, shouldValidate: true }); + } + + if (currentValues.defaults.operationQualificationCode !== null) { + form.setValue("defaults.operationQualificationCode", null, { + shouldDirty: true, + shouldValidate: true, + }); + } + + if (currentValues.defaults.exemptionReasonCode !== null) { + form.setValue("defaults.exemptionReasonCode", null, { + shouldDirty: true, + shouldValidate: true, + }); + } + } +}; + +export interface UseCompanyVerifactuProfileControllerOptions { + profile?: CompanyVerifactuProfile; +} + +export const useCompanyVerifactuProfileController = ({ + profile, +}: UseCompanyVerifactuProfileControllerOptions) => { + const { t } = useTranslation(); + const formId = useId(); + const mutation = useCompanyVerifactuProfileUpdateMutation(); + + const taxRegimesQuery = useTaxRegimesListQuery({ + criteria: { + pageSize: 999, + filters: [ + { + field: "isActive", + operator: "EQUALS", + value: "true", + }, + ], + }, + }); + + const initialValues = useMemo(() => { + if (!profile) { + return { + profileStatus: "not_configured", + exemptionReasonCode: null, + exemptionReasonDetail: "", + verifactuEffectiveAt: null, + autoRenewMode: null, + defaults: { + taxRegimeCode: null, + operationQualificationCode: null, + exemptionReasonCode: null, + }, + }; + } + + return buildCompanyVerifactuProfileForm(profile); + }, [profile]); + + const form = useHookForm({ + resolverSchema: CompanyVerifactuProfileFormSchema, + initialValues, + disabled: mutation.isPending, + }); + + useEffect(() => { + if (!profile) { + return; + } + + form.reset(buildCompanyVerifactuProfileForm(profile), { + keepDirty: false, + }); + }, [profile, form]); + + const profileStatus = form.watch("profileStatus"); + + useEffect(() => { + clearFieldsForStatus(profileStatus, form); + }, [form, profileStatus]); + + const resetForm = () => { + if (!profile) { + return; + } + + form.reset(buildCompanyVerifactuProfileForm(profile), { + keepDirty: false, + }); + }; + + const submitHandler = form.handleSubmit( + async (formData) => { + if (!formHasAnyDirty(form.formState.dirtyFields)) { + showWarningToast( + t("settings.verifactu.feedback.no_changes.title"), + t("settings.verifactu.feedback.no_changes.message") + ); + return; + } + + const params = buildUpdateCompanyVerifactuProfileParams(formData); + + try { + const updated = await mutation.mutateAsync(params); + + form.reset(buildCompanyVerifactuProfileForm(updated), { + keepDirty: false, + }); + + showSuccessToast( + t("settings.verifactu.feedback.success.title"), + t("settings.verifactu.feedback.success.message") + ); + } catch (error: unknown) { + const normalizedError = normalizeSubmitError(error); + + if (isValidationErrorCollection(normalizedError)) { + applyValidationErrorCollection(form, normalizedError); + focusFirstInputFormError(form.formState.errors, form); + showWarningToast( + t("settings.verifactu.feedback.validation.title"), + t("settings.verifactu.feedback.validation.message") + ); + return; + } + + showErrorToast( + t("settings.verifactu.feedback.error.title"), + normalizedError.message || t("settings.verifactu.feedback.error.message") + ); + } + }, + (errors: FieldErrors) => { + focusFirstInputFormError(errors, form); + showWarningToast( + t("settings.verifactu.feedback.validation.title"), + getFirstFormErrorMessage(errors, form) ?? + t("settings.verifactu.feedback.validation.message") + ); + } + ); + + const onSubmit = (event: SubmitEvent) => { + event.stopPropagation(); + submitHandler(event); + }; + + const taxRegimeOptions = useMemo(() => { + return getTaxRegimeOptions(taxRegimesQuery.data?.items ?? []); + }, [taxRegimesQuery.data?.items]); + + return { + form, + formId, + onSubmit, + resetForm, + isUpdating: mutation.isPending, + isUpdateError: mutation.isError, + updateError: mutation.error, + profileStatus, + showExemptionFields: profileStatus === "exempt", + showVerifactuFields: profileStatus === "obligated_with_verifactu", + isNotConfigured: profileStatus === "not_configured", + taxRegimeOptions, + isTaxRegimesLoading: taxRegimesQuery.isLoading, + }; +}; diff --git a/modules/customer-invoices/src/web/settings/verifactu/controllers/use-company-verifactu-settings-page-controller.ts b/modules/customer-invoices/src/web/settings/verifactu/controllers/use-company-verifactu-settings-page-controller.ts new file mode 100644 index 00000000..84d3d643 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/controllers/use-company-verifactu-settings-page-controller.ts @@ -0,0 +1,58 @@ +import { useCallback, useMemo } from "react"; + +import { useTranslation } from "../../../i18n"; +import { useCompanyVerifactuProfileQuery } from "../shared"; +import { + buildCompanyVerifactuSettingsMissingEntityPageErrorViewModel, + buildCompanyVerifactuSettingsPageErrorViewModel, + resolveCompanyVerifactuSettingsPageFeedbackState, +} from "../utils"; + +import { useCompanyVerifactuProfileController } from "./use-company-verifactu-profile.controller"; + +export const useCompanyVerifactuSettingsPageController = () => { + const { t } = useTranslation(); + const profileQuery = useCompanyVerifactuProfileQuery(); + + const profileCtrl = useCompanyVerifactuProfileController({ + profile: profileQuery.data, + }); + + const feedbackState = resolveCompanyVerifactuSettingsPageFeedbackState({ + isLoading: profileQuery.isLoading, + isLoadError: profileQuery.isError, + hasProfileData: Boolean(profileQuery.data), + error: profileQuery.error, + }); + + const retry = useCallback(async () => { + await profileQuery.refetch(); + }, [profileQuery]); + + const pageError = useMemo(() => { + if (feedbackState.hasBlockingError) { + return buildCompanyVerifactuSettingsPageErrorViewModel(profileQuery.error, (key) => t(key)); + } + + if (feedbackState.hasDataInconsistency) { + return buildCompanyVerifactuSettingsMissingEntityPageErrorViewModel((key) => t(key)); + } + + return null; + }, [ + feedbackState.hasBlockingError, + feedbackState.hasDataInconsistency, + profileQuery.error, + t, + ]); + + return { + profile: profileQuery.data, + profileCtrl, + pageError, + retry, + isRetrying: profileQuery.isFetching && !profileQuery.isLoading, + isInitialLoading: feedbackState.isInitialLoading, + mode: profileQuery.data?.configured ? "configured" : "initial", + }; +}; diff --git a/modules/customer-invoices/src/web/settings/verifactu/entities/company-verifactu-profile-form.entity.ts b/modules/customer-invoices/src/web/settings/verifactu/entities/company-verifactu-profile-form.entity.ts new file mode 100644 index 00000000..4197ec8c --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/entities/company-verifactu-profile-form.entity.ts @@ -0,0 +1,16 @@ +export interface CompanyVerifactuProfileForm { + profileStatus: + | "not_configured" + | "exempt" + | "obligated_without_verifactu" + | "obligated_with_verifactu"; + exemptionReasonCode: string | null; + exemptionReasonDetail: string; + verifactuEffectiveAt: string | null; + autoRenewMode: "disabled" | "enabled" | null; + defaults: { + taxRegimeCode: string | null; + operationQualificationCode: string | null; + exemptionReasonCode: string | null; + }; +} diff --git a/modules/customer-invoices/src/web/settings/verifactu/entities/company-verifactu-profile-form.schema.ts b/modules/customer-invoices/src/web/settings/verifactu/entities/company-verifactu-profile-form.schema.ts new file mode 100644 index 00000000..49726483 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/entities/company-verifactu-profile-form.schema.ts @@ -0,0 +1,89 @@ +import { z } from "zod/v4"; + +export const CompanyVerifactuProfileFormSchema = z + .object({ + profileStatus: z.enum([ + "not_configured", + "exempt", + "obligated_without_verifactu", + "obligated_with_verifactu", + ]), + exemptionReasonCode: z.string().trim().min(1).max(64).nullable(), + exemptionReasonDetail: z.string().trim().max(1024), + verifactuEffectiveAt: z.string().trim().min(1).nullable(), + autoRenewMode: z.enum(["disabled", "enabled"]).nullable(), + defaults: z.object({ + taxRegimeCode: z.string().trim().min(1).max(64).nullable(), + operationQualificationCode: z.string().trim().min(1).max(64).nullable(), + exemptionReasonCode: z.string().trim().min(1).max(64).nullable(), + }), + }) + .superRefine((formValues, ctx) => { + if ( + formValues.profileStatus === "obligated_with_verifactu" && + !formValues.verifactuEffectiveAt + ) { + ctx.addIssue({ + code: "custom", + path: ["verifactuEffectiveAt"], + message: "VeriFactu effective date is required", + }); + } + + if ( + formValues.profileStatus !== "obligated_with_verifactu" && + formValues.verifactuEffectiveAt !== null + ) { + ctx.addIssue({ + code: "custom", + path: ["verifactuEffectiveAt"], + message: "VeriFactu effective date is only valid when VeriFactu is enabled", + }); + } + + if ( + formValues.profileStatus !== "obligated_with_verifactu" && + formValues.autoRenewMode !== null + ) { + ctx.addIssue({ + code: "custom", + path: ["autoRenewMode"], + message: "Auto renew is only valid when VeriFactu is enabled", + }); + } + + if (formValues.profileStatus === "not_configured") { + const hasDefaults = Object.values(formValues.defaults).some((value) => value !== null); + + if (hasDefaults) { + ctx.addIssue({ + code: "custom", + path: ["defaults"], + message: "Defaults are not allowed while the profile is not configured", + }); + } + + if (formValues.exemptionReasonCode !== null || formValues.exemptionReasonDetail.trim() !== "") { + ctx.addIssue({ + code: "custom", + path: ["exemptionReasonDetail"], + message: "Exemption information is not allowed while the profile is not configured", + }); + } + } + + if ( + formValues.profileStatus === "obligated_without_verifactu" && + (formValues.exemptionReasonCode !== null || formValues.exemptionReasonDetail.trim() !== "") + ) { + ctx.addIssue({ + code: "custom", + path: ["exemptionReasonDetail"], + message: "Exemption information is only valid for exempt profiles", + }); + } + }); + +export type CompanyVerifactuProfileFormSchemaType = z.infer< + typeof CompanyVerifactuProfileFormSchema +>; diff --git a/modules/customer-invoices/src/web/settings/verifactu/entities/company-verifactu-settings-page-error-view-model.entity.ts b/modules/customer-invoices/src/web/settings/verifactu/entities/company-verifactu-settings-page-error-view-model.entity.ts new file mode 100644 index 00000000..5e8beee8 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/entities/company-verifactu-settings-page-error-view-model.entity.ts @@ -0,0 +1,9 @@ +import type { PageErrorStateVariant } from "@erp/core/components"; + +export interface CompanyVerifactuSettingsPageErrorViewModel { + variant: PageErrorStateVariant; + title: string; + description: string; + canRetry: boolean; + reference?: string; +} diff --git a/modules/customer-invoices/src/web/settings/verifactu/entities/index.ts b/modules/customer-invoices/src/web/settings/verifactu/entities/index.ts new file mode 100644 index 00000000..3810771d --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/entities/index.ts @@ -0,0 +1,3 @@ +export * from "./company-verifactu-profile-form.entity"; +export * from "./company-verifactu-profile-form.schema"; +export * from "./company-verifactu-settings-page-error-view-model.entity"; diff --git a/modules/customer-invoices/src/web/settings/verifactu/index.ts b/modules/customer-invoices/src/web/settings/verifactu/index.ts new file mode 100644 index 00000000..4aedf593 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/index.ts @@ -0,0 +1 @@ +export * from "./ui"; diff --git a/modules/customer-invoices/src/web/settings/verifactu/shared/adapters/get-company-verifactu-profile.adapter.test.ts b/modules/customer-invoices/src/web/settings/verifactu/shared/adapters/get-company-verifactu-profile.adapter.test.ts new file mode 100644 index 00000000..e369b14c --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/shared/adapters/get-company-verifactu-profile.adapter.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { CompanyVerifactuProfileResponseDTO } from "../../../../../common"; + +import { GetCompanyVerifactuProfileAdapter } from "./get-company-verifactu-profile.adapter"; + +const buildDto = (): CompanyVerifactuProfileResponseDTO => ({ + profile_status: "obligated_with_verifactu", + configured: true, + uses_verifactu: true, + exemption_reason_code: null, + exemption_reason_detail: null, + verifactu_effective_at: "2026-08-12T00:00:00.000Z", + auto_renew_mode: "enabled", + defaults: { + tax_regime_code: "01", + operation_qualification_code: null, + exemption_reason_code: null, + }, + updated_at: "2026-08-12T10:00:00.000Z", +}); + +test("adapta snake_case a camelCase preservando nullables", () => { + const profile = GetCompanyVerifactuProfileAdapter.fromDto(buildDto()); + + assert.equal(profile.profileStatus, "obligated_with_verifactu"); + assert.equal(profile.configured, true); + assert.equal(profile.usesVerifactu, true); + assert.equal(profile.exemptionReasonCode, null); + assert.equal(profile.verifactuEffectiveAt, "2026-08-12T00:00:00.000Z"); + assert.equal(profile.autoRenewMode, "enabled"); + assert.equal(profile.defaults.taxRegimeCode, "01"); + assert.equal(profile.defaults.operationQualificationCode, null); + assert.equal(profile.updatedAt, "2026-08-12T10:00:00.000Z"); +}); diff --git a/modules/customer-invoices/src/web/settings/verifactu/shared/adapters/get-company-verifactu-profile.adapter.ts b/modules/customer-invoices/src/web/settings/verifactu/shared/adapters/get-company-verifactu-profile.adapter.ts new file mode 100644 index 00000000..daad18d6 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/shared/adapters/get-company-verifactu-profile.adapter.ts @@ -0,0 +1,22 @@ +import type { CompanyVerifactuProfileResponseDTO } from "../../../../../common"; +import type { CompanyVerifactuProfile } from "../entities"; + +export const GetCompanyVerifactuProfileAdapter = { + fromDto(dto: CompanyVerifactuProfileResponseDTO): CompanyVerifactuProfile { + return { + profileStatus: dto.profile_status, + configured: dto.configured, + usesVerifactu: dto.uses_verifactu, + exemptionReasonCode: dto.exemption_reason_code, + exemptionReasonDetail: dto.exemption_reason_detail, + verifactuEffectiveAt: dto.verifactu_effective_at, + autoRenewMode: dto.auto_renew_mode, + defaults: { + taxRegimeCode: dto.defaults.tax_regime_code, + operationQualificationCode: dto.defaults.operation_qualification_code, + exemptionReasonCode: dto.defaults.exemption_reason_code, + }, + updatedAt: dto.updated_at, + }; + }, +}; diff --git a/modules/customer-invoices/src/web/settings/verifactu/shared/adapters/index.ts b/modules/customer-invoices/src/web/settings/verifactu/shared/adapters/index.ts new file mode 100644 index 00000000..545f0b57 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/shared/adapters/index.ts @@ -0,0 +1,2 @@ +export * from "./get-company-verifactu-profile.adapter"; +export * from "./update-company-verifactu-profile.adapter"; diff --git a/modules/customer-invoices/src/web/settings/verifactu/shared/adapters/update-company-verifactu-profile.adapter.ts b/modules/customer-invoices/src/web/settings/verifactu/shared/adapters/update-company-verifactu-profile.adapter.ts new file mode 100644 index 00000000..af306c4e --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/shared/adapters/update-company-verifactu-profile.adapter.ts @@ -0,0 +1,10 @@ +import type { CompanyVerifactuProfileResponseDTO } from "../../../../../common"; +import type { CompanyVerifactuProfile } from "../entities"; + +import { GetCompanyVerifactuProfileAdapter } from "./get-company-verifactu-profile.adapter"; + +export const UpdateCompanyVerifactuProfileAdapter = { + fromDto(dto: CompanyVerifactuProfileResponseDTO): CompanyVerifactuProfile { + return GetCompanyVerifactuProfileAdapter.fromDto(dto); + }, +}; diff --git a/modules/customer-invoices/src/web/settings/verifactu/shared/api/get-company-verifactu-profile.api.ts b/modules/customer-invoices/src/web/settings/verifactu/shared/api/get-company-verifactu-profile.api.ts new file mode 100644 index 00000000..ebe5c52a --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/shared/api/get-company-verifactu-profile.api.ts @@ -0,0 +1,23 @@ +import type { IDataSource } from "@erp/core/client"; + +import type { CompanyVerifactuProfileResponseDTO } from "../../../../../common"; + +export interface GetCompanyVerifactuProfileParams { + signal?: AbortSignal; +} + +export type GetCompanyVerifactuProfileResult = CompanyVerifactuProfileResponseDTO; + +export const getCompanyVerifactuProfile = ( + dataSource: IDataSource, + params: GetCompanyVerifactuProfileParams = {} +): Promise => { + const { signal } = params; + + return dataSource.custom, CompanyVerifactuProfileResponseDTO>({ + path: "customer-invoices/settings/verifactu", + method: "get", + signal, + data: {}, + }); +}; diff --git a/modules/customer-invoices/src/web/settings/verifactu/shared/api/index.ts b/modules/customer-invoices/src/web/settings/verifactu/shared/api/index.ts new file mode 100644 index 00000000..f47255b5 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/shared/api/index.ts @@ -0,0 +1,2 @@ +export * from "./get-company-verifactu-profile.api"; +export * from "./update-company-verifactu-profile.api"; diff --git a/modules/customer-invoices/src/web/settings/verifactu/shared/api/update-company-verifactu-profile.api.ts b/modules/customer-invoices/src/web/settings/verifactu/shared/api/update-company-verifactu-profile.api.ts new file mode 100644 index 00000000..bc5f4587 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/shared/api/update-company-verifactu-profile.api.ts @@ -0,0 +1,25 @@ +import type { IDataSource } from "@erp/core/client"; + +import type { + CompanyVerifactuProfileResponseDTO, + UpdateCompanyVerifactuProfileRequestDTO, +} from "../../../../../common"; + +export interface UpdateCompanyVerifactuProfileParams { + data: UpdateCompanyVerifactuProfileRequestDTO; +} + +export type UpdateCompanyVerifactuProfileResult = CompanyVerifactuProfileResponseDTO; + +export const updateCompanyVerifactuProfile = ( + dataSource: IDataSource, + params: UpdateCompanyVerifactuProfileParams +): Promise => { + return dataSource.custom( + { + path: "customer-invoices/settings/verifactu", + method: "put", + data: params.data, + } + ); +}; diff --git a/modules/customer-invoices/src/web/settings/verifactu/shared/entities/company-verifactu-profile.entity.ts b/modules/customer-invoices/src/web/settings/verifactu/shared/entities/company-verifactu-profile.entity.ts new file mode 100644 index 00000000..628b29b0 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/shared/entities/company-verifactu-profile.entity.ts @@ -0,0 +1,21 @@ +export interface CompanyVerifactuDefaults { + taxRegimeCode: string | null; + operationQualificationCode: string | null; + exemptionReasonCode: string | null; +} + +export 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: CompanyVerifactuDefaults; + updatedAt: string | null; +} diff --git a/modules/customer-invoices/src/web/settings/verifactu/shared/entities/index.ts b/modules/customer-invoices/src/web/settings/verifactu/shared/entities/index.ts new file mode 100644 index 00000000..a7552be7 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/shared/entities/index.ts @@ -0,0 +1 @@ +export * from "./company-verifactu-profile.entity"; diff --git a/modules/customer-invoices/src/web/settings/verifactu/shared/hooks/index.ts b/modules/customer-invoices/src/web/settings/verifactu/shared/hooks/index.ts new file mode 100644 index 00000000..1c526044 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/shared/hooks/index.ts @@ -0,0 +1,3 @@ +export * from "./keys"; +export * from "./use-company-verifactu-profile-query"; +export * from "./use-company-verifactu-profile-update-mutation"; diff --git a/modules/customer-invoices/src/web/settings/verifactu/shared/hooks/keys.ts b/modules/customer-invoices/src/web/settings/verifactu/shared/hooks/keys.ts new file mode 100644 index 00000000..23798497 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/shared/hooks/keys.ts @@ -0,0 +1,11 @@ +export const COMPANY_VERIFACTU_PROFILE_QUERY_KEY = [ + "customer-invoices", + "settings", + "verifactu-profile", +] as const; + +export const UPDATE_COMPANY_VERIFACTU_PROFILE_MUTATION_KEY = [ + "customer-invoices", + "settings", + "verifactu-profile:update", +] as const; diff --git a/modules/customer-invoices/src/web/settings/verifactu/shared/hooks/to-validation-errors.ts b/modules/customer-invoices/src/web/settings/verifactu/shared/hooks/to-validation-errors.ts new file mode 100644 index 00000000..43e54858 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/shared/hooks/to-validation-errors.ts @@ -0,0 +1,9 @@ +import type { ValidationErrorDetail } from "@repo/rdx-ddd"; +import type { ZodError } from "zod"; + +export const toValidationErrors = (error: ZodError): ValidationErrorDetail[] => + error.issues.map((issue) => ({ + path: issue.path.join("."), + message: issue.message, + value: issue.input, + })); diff --git a/modules/customer-invoices/src/web/settings/verifactu/shared/hooks/use-company-verifactu-profile-query.ts b/modules/customer-invoices/src/web/settings/verifactu/shared/hooks/use-company-verifactu-profile-query.ts new file mode 100644 index 00000000..2bec9ed0 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/shared/hooks/use-company-verifactu-profile-query.ts @@ -0,0 +1,27 @@ +import { useDataSource } from "@erp/core/hooks"; +import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; + +import { GetCompanyVerifactuProfileAdapter } from "../adapters"; +import { getCompanyVerifactuProfile } from "../api"; +import type { CompanyVerifactuProfile } from "../entities"; + +import { COMPANY_VERIFACTU_PROFILE_QUERY_KEY } from "./keys"; + +export interface CompanyVerifactuProfileQueryOptions { + enabled?: boolean; +} + +export const useCompanyVerifactuProfileQuery = ( + options?: CompanyVerifactuProfileQueryOptions +): UseQueryResult => { + const dataSource = useDataSource(); + + return useQuery({ + queryKey: COMPANY_VERIFACTU_PROFILE_QUERY_KEY, + queryFn: async ({ signal }) => { + const dto = await getCompanyVerifactuProfile(dataSource, { signal }); + return GetCompanyVerifactuProfileAdapter.fromDto(dto); + }, + enabled: options?.enabled ?? true, + }); +}; diff --git a/modules/customer-invoices/src/web/settings/verifactu/shared/hooks/use-company-verifactu-profile-update-mutation.ts b/modules/customer-invoices/src/web/settings/verifactu/shared/hooks/use-company-verifactu-profile-update-mutation.ts new file mode 100644 index 00000000..402832a7 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/shared/hooks/use-company-verifactu-profile-update-mutation.ts @@ -0,0 +1,35 @@ +import { useDataSource } from "@erp/core/hooks"; +import { ValidationErrorCollection } from "@repo/rdx-ddd"; +import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; + +import { UpdateCompanyVerifactuProfileRequestSchema } from "../../../../../common"; +import { UpdateCompanyVerifactuProfileAdapter } from "../adapters"; +import type { UpdateCompanyVerifactuProfileParams } from "../api"; +import { updateCompanyVerifactuProfile } from "../api"; +import type { CompanyVerifactuProfile } from "../entities"; + +import { COMPANY_VERIFACTU_PROFILE_QUERY_KEY, UPDATE_COMPANY_VERIFACTU_PROFILE_MUTATION_KEY } from "./keys"; +import { toValidationErrors } from "./to-validation-errors"; + +export const useCompanyVerifactuProfileUpdateMutation = () => { + const queryClient = useQueryClient(); + const dataSource = useDataSource(); + + return useMutation({ + mutationKey: UPDATE_COMPANY_VERIFACTU_PROFILE_MUTATION_KEY, + mutationFn: async (params) => { + const result = UpdateCompanyVerifactuProfileRequestSchema.safeParse(params.data); + + if (!result.success) { + throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error)); + } + + const dto = await updateCompanyVerifactuProfile(dataSource, params); + + return UpdateCompanyVerifactuProfileAdapter.fromDto(dto); + }, + onSuccess: async (profile) => { + queryClient.setQueryData(COMPANY_VERIFACTU_PROFILE_QUERY_KEY, profile); + }, + }); +}; diff --git a/modules/customer-invoices/src/web/settings/verifactu/shared/index.ts b/modules/customer-invoices/src/web/settings/verifactu/shared/index.ts new file mode 100644 index 00000000..01b46929 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/shared/index.ts @@ -0,0 +1,4 @@ +export * from "./adapters"; +export * from "./api"; +export * from "./entities"; +export * from "./hooks"; diff --git a/modules/customer-invoices/src/web/settings/verifactu/ui/components/company-verifactu-settings-skeleton.tsx b/modules/customer-invoices/src/web/settings/verifactu/ui/components/company-verifactu-settings-skeleton.tsx new file mode 100644 index 00000000..4431f473 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/ui/components/company-verifactu-settings-skeleton.tsx @@ -0,0 +1,16 @@ +import { Skeleton } from "@repo/shadcn-ui/components"; + +export const CompanyVerifactuSettingsSkeleton = () => { + return ( +
    +
    + + +
    + + + + +
    + ); +}; diff --git a/modules/customer-invoices/src/web/settings/verifactu/ui/components/index.ts b/modules/customer-invoices/src/web/settings/verifactu/ui/components/index.ts new file mode 100644 index 00000000..d00fe582 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/ui/components/index.ts @@ -0,0 +1 @@ +export * from "./company-verifactu-settings-skeleton"; diff --git a/modules/customer-invoices/src/web/settings/verifactu/ui/index.ts b/modules/customer-invoices/src/web/settings/verifactu/ui/index.ts new file mode 100644 index 00000000..9164b275 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/ui/index.ts @@ -0,0 +1,3 @@ +export * from "./components"; +export * from "./pages"; +export * from "./sections"; diff --git a/modules/customer-invoices/src/web/settings/verifactu/ui/pages/company-verifactu-settings-page.tsx b/modules/customer-invoices/src/web/settings/verifactu/ui/pages/company-verifactu-settings-page.tsx new file mode 100644 index 00000000..0c13670f --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/ui/pages/company-verifactu-settings-page.tsx @@ -0,0 +1,132 @@ +import { ErrorAlert, PageErrorState } from "@erp/core/components"; +import { UnsavedChangesProvider } from "@erp/core/hooks"; +import { + AppContent, + CancelActionButton, + RdxEditorStickyFooter, + RhfSubmitActionButton, +} from "@repo/rdx-ui/components"; +import { FormProvider } from "react-hook-form"; + +import { useTranslation } from "../../../../i18n"; +import { useCompanyVerifactuSettingsPageController } from "../../controllers"; + +import { CompanyVerifactuSettingsSkeleton } from "../components/company-verifactu-settings-skeleton"; +import { CompanyVerifactuConfigurationSection } from "../sections/company-verifactu-configuration-section"; +import { CompanyVerifactuDefaultsSection } from "../sections/company-verifactu-defaults-section"; +import { CompanyVerifactuStatusSection } from "../sections/company-verifactu-status-section"; + +export const CompanyVerifactuSettingsPage = () => { + const { t } = useTranslation(); + const ctrl = useCompanyVerifactuSettingsPageController(); + const { profileCtrl } = ctrl; + + if (ctrl.isInitialLoading) { + return ; + } + + if (ctrl.pageError) { + return ( + + + + ); + } + + return ( + + +
    +
    +

    + {t("settings.verifactu.title")} +

    +

    + {t("settings.verifactu.description")} +

    + {ctrl.mode === "initial" ? ( +

    + {t("settings.verifactu.initial_mode_hint")} +

    + ) : null} +
    + + {profileCtrl.isUpdateError ? ( + + ) : null} + +
    + + + {(profileCtrl.showExemptionFields || profileCtrl.showVerifactuFields) ? ( + + ) : null} + + + + +

    + {profileCtrl.isUpdating + ? t("settings.verifactu.feedback.saving") + : t("settings.verifactu.feedback.idle")} +

    + + + } + secondary={ + + } + /> +
    +
    +
    + ); +}; diff --git a/modules/customer-invoices/src/web/settings/verifactu/ui/pages/index.ts b/modules/customer-invoices/src/web/settings/verifactu/ui/pages/index.ts new file mode 100644 index 00000000..3d1e8414 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/ui/pages/index.ts @@ -0,0 +1 @@ +export * from "./company-verifactu-settings-page"; diff --git a/modules/customer-invoices/src/web/settings/verifactu/ui/sections/company-verifactu-configuration-section.tsx b/modules/customer-invoices/src/web/settings/verifactu/ui/sections/company-verifactu-configuration-section.tsx new file mode 100644 index 00000000..ad9626b7 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/ui/sections/company-verifactu-configuration-section.tsx @@ -0,0 +1,81 @@ +import type { SelectFieldItem } from "@repo/rdx-ui/components"; +import { + DatePickerField, + RdxEditorSection, + RdxFormGrid, + SelectField, + TextAreaField, +} from "@repo/rdx-ui/components"; +import { ShieldCheckIcon } from "lucide-react"; + +import { useTranslation } from "../../../../i18n"; + +interface CompanyVerifactuConfigurationSectionProps { + showExemptionFields: boolean; + showVerifactuFields: boolean; + disabled?: boolean; +} + +export const CompanyVerifactuConfigurationSection = ({ + showExemptionFields, + showVerifactuFields, + disabled = false, +}: CompanyVerifactuConfigurationSectionProps) => { + const { t } = useTranslation(); + const autoRenewItems: SelectFieldItem[] = [ + { value: "enabled", label: t("settings.verifactu.auto_renew.enabled") }, + { value: "disabled", label: t("settings.verifactu.auto_renew.disabled") }, + ]; + + return ( + } + title={t("settings.verifactu.configuration.title")} + variant="panel" + > + + {showExemptionFields ? ( + <> +
    + {t("settings.verifactu.exemption_reason.unavailable")} +
    + + + + ) : null} + + {showVerifactuFields ? ( + <> + + + + + ) : null} +
    +
    + ); +}; diff --git a/modules/customer-invoices/src/web/settings/verifactu/ui/sections/company-verifactu-defaults-section.tsx b/modules/customer-invoices/src/web/settings/verifactu/ui/sections/company-verifactu-defaults-section.tsx new file mode 100644 index 00000000..6205e477 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/ui/sections/company-verifactu-defaults-section.tsx @@ -0,0 +1,50 @@ +import { + RdxEditorSection, + RdxFormGrid, + SelectField, + type SelectFieldItem, +} from "@repo/rdx-ui/components"; +import { LandmarkIcon } from "lucide-react"; + +import { useTranslation } from "../../../../i18n"; + +interface CompanyVerifactuDefaultsSectionProps { + taxRegimeOptions: SelectFieldItem[]; + disabled?: boolean; + isTaxRegimesLoading?: boolean; +} + +export const CompanyVerifactuDefaultsSection = ({ + taxRegimeOptions, + disabled = false, + isTaxRegimesLoading = false, +}: CompanyVerifactuDefaultsSectionProps) => { + const { t } = useTranslation(); + + return ( + } + title={t("settings.verifactu.defaults.title")} + variant="panel" + > + + + +
    + {t("settings.verifactu.defaults.pending_catalogs")} +
    +
    +
    + ); +}; diff --git a/modules/customer-invoices/src/web/settings/verifactu/ui/sections/company-verifactu-status-section.tsx b/modules/customer-invoices/src/web/settings/verifactu/ui/sections/company-verifactu-status-section.tsx new file mode 100644 index 00000000..4ddaed54 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/ui/sections/company-verifactu-status-section.tsx @@ -0,0 +1,102 @@ +import { FormFieldLabel, RdxEditorSection } from "@repo/rdx-ui/components"; +import { + Field, + FieldContent, + FieldDescription, + FieldError, + FieldLabel, + RadioGroup, + RadioGroupItem, +} from "@repo/shadcn-ui/components"; +import { FileCogIcon } from "lucide-react"; +import { Controller, useFormContext } from "react-hook-form"; + +import { useTranslation } from "../../../../i18n"; +import type { CompanyVerifactuProfileForm } from "../../entities"; + +export const CompanyVerifactuStatusSection = () => { + const { t } = useTranslation(); + const { control } = useFormContext(); + + return ( + } + title={t("settings.verifactu.status.title")} + variant="panel" + > + ( + +
    + + {t("settings.verifactu.status.label")} + + + + + + + + {t("settings.verifactu.status.exempt")} + + + + + + + + + {t("settings.verifactu.status.obligated_without_verifactu")} + + + + + + + + + {t("settings.verifactu.status.obligated_with_verifactu")} + + + + +
    + + {t("settings.verifactu.status.help")} + +
    + )} + /> +
    + ); +}; diff --git a/modules/customer-invoices/src/web/settings/verifactu/ui/sections/index.ts b/modules/customer-invoices/src/web/settings/verifactu/ui/sections/index.ts new file mode 100644 index 00000000..e2cb0d42 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/ui/sections/index.ts @@ -0,0 +1,3 @@ +export * from "./company-verifactu-configuration-section"; +export * from "./company-verifactu-defaults-section"; +export * from "./company-verifactu-status-section"; diff --git a/modules/customer-invoices/src/web/settings/verifactu/utils/build-company-verifactu-profile-form.ts b/modules/customer-invoices/src/web/settings/verifactu/utils/build-company-verifactu-profile-form.ts new file mode 100644 index 00000000..96db881b --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/utils/build-company-verifactu-profile-form.ts @@ -0,0 +1,27 @@ +import type { CompanyVerifactuProfile } from "../shared"; +import type { CompanyVerifactuProfileForm } from "../entities"; + +const toCivilDate = (value: string | null): string | null => { + if (!value) { + return null; + } + + return value.slice(0, 10); +}; + +export const buildCompanyVerifactuProfileForm = ( + profile: CompanyVerifactuProfile +): CompanyVerifactuProfileForm => { + return { + profileStatus: profile.profileStatus, + exemptionReasonCode: profile.exemptionReasonCode, + exemptionReasonDetail: profile.exemptionReasonDetail ?? "", + verifactuEffectiveAt: toCivilDate(profile.verifactuEffectiveAt), + autoRenewMode: profile.autoRenewMode, + defaults: { + taxRegimeCode: profile.defaults.taxRegimeCode, + operationQualificationCode: profile.defaults.operationQualificationCode, + exemptionReasonCode: profile.defaults.exemptionReasonCode, + }, + }; +}; diff --git a/modules/customer-invoices/src/web/settings/verifactu/utils/build-company-verifactu-settings-page-error-view-model.test.ts b/modules/customer-invoices/src/web/settings/verifactu/utils/build-company-verifactu-settings-page-error-view-model.test.ts new file mode 100644 index 00000000..58741577 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/utils/build-company-verifactu-settings-page-error-view-model.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ApiClientError } from "@erp/core/client"; + +import { + buildCompanyVerifactuSettingsMissingEntityPageErrorViewModel, + buildCompanyVerifactuSettingsPageErrorViewModel, +} from "./build-company-verifactu-settings-page-error-view-model"; + +const translate = (key: string) => + ({ + "settings.verifactu.page_error.notFound.title": "Configuración no disponible", + "settings.verifactu.page_error.notFound.description": + "La configuración solicitada no está disponible.", + "settings.verifactu.page_error.forbidden.title": + "No tienes acceso a esta configuración", + "settings.verifactu.page_error.forbidden.description": + "No dispones de permisos para consultar la configuración VeriFactu de la empresa activa.", + "settings.verifactu.page_error.network.title": "No se pudo conectar con el servidor", + "settings.verifactu.page_error.network.description": + "Comprueba tu conexión e inténtalo de nuevo.", + "settings.verifactu.page_error.timeout.title": "La carga está tardando demasiado", + "settings.verifactu.page_error.timeout.description": + "El servidor no respondió dentro del tiempo esperado.", + "settings.verifactu.page_error.server.title": "No se pudo cargar la configuración", + "settings.verifactu.page_error.server.description": + "Se produjo un error interno al recuperar la configuración VeriFactu.", + "settings.verifactu.page_error.unavailable.title": + "Servicio temporalmente no disponible", + "settings.verifactu.page_error.unavailable.description": + "El servicio de VeriFactu no está disponible en este momento.", + "settings.verifactu.page_error.unknown.title": "No se pudo cargar la configuración", + "settings.verifactu.page_error.unknown.description": + "Ha ocurrido un error inesperado al cargar la configuración VeriFactu.", + "settings.verifactu.page_error.missing_entity.title": "No se pudo cargar la configuración", + "settings.verifactu.page_error.missing_entity.description": + "La respuesta recibida no contiene un perfil VeriFactu válido.", + })[key] ?? key; + +test("mapea forbidden y network con la política de retry esperada", () => { + const forbidden = buildCompanyVerifactuSettingsPageErrorViewModel( + new ApiClientError({ kind: "forbidden", message: "technical" }), + translate + ); + const network = buildCompanyVerifactuSettingsPageErrorViewModel( + new ApiClientError({ kind: "network", message: "technical", correlationId: "corr-1" }), + translate + ); + + assert.equal(forbidden?.variant, "forbidden"); + assert.equal(forbidden?.canRetry, false); + assert.equal(network?.variant, "network"); + assert.equal(network?.canRetry, true); + assert.equal(network?.reference, "corr-1"); +}); + +test("ignora cancelled y marca inconsistencia cuando falta la entidad", () => { + const cancelled = buildCompanyVerifactuSettingsPageErrorViewModel( + new ApiClientError({ kind: "cancelled", message: "technical" }), + translate + ); + const missing = buildCompanyVerifactuSettingsMissingEntityPageErrorViewModel(translate); + + assert.equal(cancelled, null); + assert.equal(missing.variant, "unknown"); + assert.equal(missing.canRetry, true); +}); diff --git a/modules/customer-invoices/src/web/settings/verifactu/utils/build-company-verifactu-settings-page-error-view-model.ts b/modules/customer-invoices/src/web/settings/verifactu/utils/build-company-verifactu-settings-page-error-view-model.ts new file mode 100644 index 00000000..044f392c --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/utils/build-company-verifactu-settings-page-error-view-model.ts @@ -0,0 +1,64 @@ +import { isApiClientError } from "@erp/core/client"; + +import type { CompanyVerifactuSettingsPageErrorViewModel } from "../entities"; + +interface Translate { + (key: string): string; +} + +const buildErrorVariant = ( + error: unknown +): CompanyVerifactuSettingsPageErrorViewModel["variant"] | "cancelled" => { + if (!isApiClientError(error)) { + return "unknown"; + } + + switch (error.kind) { + case "cancelled": + return "cancelled"; + case "notFound": + return "notFound"; + case "forbidden": + return "forbidden"; + case "network": + return "network"; + case "timeout": + return "timeout"; + case "server": + return "server"; + case "unavailable": + return "unavailable"; + default: + return "unknown"; + } +}; + +export const buildCompanyVerifactuSettingsPageErrorViewModel = ( + error: unknown, + translate: Translate +): CompanyVerifactuSettingsPageErrorViewModel | null => { + const variant = buildErrorVariant(error); + + if (variant === "cancelled") { + return null; + } + + return { + variant, + title: translate(`settings.verifactu.page_error.${variant}.title`), + description: translate(`settings.verifactu.page_error.${variant}.description`), + canRetry: !["forbidden", "notFound"].includes(variant), + reference: isApiClientError(error) ? error.correlationId : undefined, + }; +}; + +export const buildCompanyVerifactuSettingsMissingEntityPageErrorViewModel = ( + translate: Translate +): CompanyVerifactuSettingsPageErrorViewModel => { + return { + variant: "unknown", + title: translate("settings.verifactu.page_error.missing_entity.title"), + description: translate("settings.verifactu.page_error.missing_entity.description"), + canRetry: true, + }; +}; diff --git a/modules/customer-invoices/src/web/settings/verifactu/utils/build-update-company-verifactu-profile-params.test.ts b/modules/customer-invoices/src/web/settings/verifactu/utils/build-update-company-verifactu-profile-params.test.ts new file mode 100644 index 00000000..3d13ad37 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/utils/build-update-company-verifactu-profile-params.test.ts @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { CompanyVerifactuProfileForm } from "../entities"; + +import { buildUpdateCompanyVerifactuProfileParams } from "./build-update-company-verifactu-profile-params"; + +const buildForm = ( + overrides: Partial = {} +): CompanyVerifactuProfileForm => ({ + profileStatus: "obligated_with_verifactu", + exemptionReasonCode: "E1", + exemptionReasonDetail: " detalle ", + verifactuEffectiveAt: "2026-08-12", + autoRenewMode: "enabled", + defaults: { + taxRegimeCode: "01", + operationQualificationCode: "OP1", + exemptionReasonCode: "EX1", + }, + ...overrides, +}); + +test("limpia campos VeriFactu cuando el perfil es exento", () => { + const result = buildUpdateCompanyVerifactuProfileParams( + buildForm({ + profileStatus: "exempt", + }) + ); + + assert.equal(result.data.profile_status, "exempt"); + assert.equal(result.data.exemption_reason_code, "E1"); + assert.equal(result.data.exemption_reason_detail, "detalle"); + assert.equal(result.data.verifactu_effective_at, null); + assert.equal(result.data.auto_renew_mode, null); +}); + +test("limpia effectiveAt y autoRenew cuando el perfil es obligado sin VeriFactu", () => { + const result = buildUpdateCompanyVerifactuProfileParams( + buildForm({ + profileStatus: "obligated_without_verifactu", + }) + ); + + assert.equal(result.data.profile_status, "obligated_without_verifactu"); + assert.equal(result.data.exemption_reason_code, null); + assert.equal(result.data.verifactu_effective_at, null); + assert.equal(result.data.auto_renew_mode, null); +}); + +test("conserva la fecha efectiva como ISO UTC cuando VeriFactu está activo", () => { + const result = buildUpdateCompanyVerifactuProfileParams( + buildForm({ + profileStatus: "obligated_with_verifactu", + verifactuEffectiveAt: "2026-09-01", + autoRenewMode: "disabled", + }) + ); + + assert.equal(result.data.verifactu_effective_at, "2026-09-01T00:00:00.000Z"); + assert.equal(result.data.auto_renew_mode, "disabled"); +}); + +test("normaliza vacíos a null y limpia defaults en not_configured", () => { + const result = buildUpdateCompanyVerifactuProfileParams( + buildForm({ + profileStatus: "not_configured", + exemptionReasonCode: " ", + exemptionReasonDetail: " ", + verifactuEffectiveAt: null, + autoRenewMode: null, + defaults: { + taxRegimeCode: " ", + operationQualificationCode: null, + exemptionReasonCode: " ", + }, + }) + ); + + assert.equal(result.data.exemption_reason_code, null); + assert.equal(result.data.exemption_reason_detail, null); + assert.deepEqual(result.data.defaults, { + tax_regime_code: null, + operation_qualification_code: null, + exemption_reason_code: null, + }); +}); diff --git a/modules/customer-invoices/src/web/settings/verifactu/utils/build-update-company-verifactu-profile-params.ts b/modules/customer-invoices/src/web/settings/verifactu/utils/build-update-company-verifactu-profile-params.ts new file mode 100644 index 00000000..95c017f1 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/utils/build-update-company-verifactu-profile-params.ts @@ -0,0 +1,54 @@ +import type { UpdateCompanyVerifactuProfileParams } from "../shared"; +import type { CompanyVerifactuProfileForm } from "../entities"; + +const normalizeNullableText = (value: string | null | undefined): string | null => { + if (value === null || value === undefined) { + return null; + } + + const trimmed = value.trim(); + + return trimmed.length > 0 ? trimmed : null; +}; + +const toUtcMidnightIsoString = (value: string | null): string | null => { + if (!value) { + return null; + } + + return `${value}T00:00:00.000Z`; +}; + +export const buildUpdateCompanyVerifactuProfileParams = ( + formData: CompanyVerifactuProfileForm +): UpdateCompanyVerifactuProfileParams => { + const profileStatus = formData.profileStatus; + const isNotConfigured = profileStatus === "not_configured"; + const isExempt = profileStatus === "exempt"; + const usesVerifactu = profileStatus === "obligated_with_verifactu"; + + return { + data: { + profile_status: profileStatus, + exemption_reason_code: isExempt ? normalizeNullableText(formData.exemptionReasonCode) : null, + exemption_reason_detail: isExempt + ? normalizeNullableText(formData.exemptionReasonDetail) + : null, + verifactu_effective_at: usesVerifactu + ? toUtcMidnightIsoString(formData.verifactuEffectiveAt) + : null, + auto_renew_mode: usesVerifactu ? formData.autoRenewMode : null, + defaults: { + tax_regime_code: isNotConfigured + ? null + : normalizeNullableText(formData.defaults.taxRegimeCode), + operation_qualification_code: isNotConfigured + ? null + : normalizeNullableText(formData.defaults.operationQualificationCode), + exemption_reason_code: isNotConfigured + ? null + : normalizeNullableText(formData.defaults.exemptionReasonCode), + }, + }, + }; +}; diff --git a/modules/customer-invoices/src/web/settings/verifactu/utils/index.ts b/modules/customer-invoices/src/web/settings/verifactu/utils/index.ts new file mode 100644 index 00000000..a0461a3e --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/utils/index.ts @@ -0,0 +1,4 @@ +export * from "./build-company-verifactu-profile-form"; +export * from "./build-update-company-verifactu-profile-params"; +export * from "./build-company-verifactu-settings-page-error-view-model"; +export * from "./resolve-company-verifactu-settings-page-feedback-state"; diff --git a/modules/customer-invoices/src/web/settings/verifactu/utils/resolve-company-verifactu-settings-page-feedback-state.test.ts b/modules/customer-invoices/src/web/settings/verifactu/utils/resolve-company-verifactu-settings-page-feedback-state.test.ts new file mode 100644 index 00000000..ed58ecd3 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/utils/resolve-company-verifactu-settings-page-feedback-state.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ApiClientError } from "@erp/core/client"; + +import { resolveCompanyVerifactuSettingsPageFeedbackState } from "./resolve-company-verifactu-settings-page-feedback-state"; + +test("mantiene loading mientras la carga inicial sigue pendiente", () => { + assert.deepEqual( + resolveCompanyVerifactuSettingsPageFeedbackState({ + isLoading: true, + isLoadError: false, + hasProfileData: false, + error: null, + }), + { + isInitialLoading: true, + hasBlockingError: false, + hasDataInconsistency: false, + } + ); +}); + +test("ignora cancelled sin mostrar error bloqueante", () => { + assert.deepEqual( + resolveCompanyVerifactuSettingsPageFeedbackState({ + isLoading: false, + isLoadError: true, + hasProfileData: false, + error: new ApiClientError({ kind: "cancelled", message: "technical" }), + }), + { + isInitialLoading: true, + hasBlockingError: false, + hasDataInconsistency: false, + } + ); +}); + +test("detecta error bloqueante e inconsistencia cuando corresponde", () => { + assert.deepEqual( + resolveCompanyVerifactuSettingsPageFeedbackState({ + isLoading: false, + isLoadError: true, + hasProfileData: false, + error: new ApiClientError({ kind: "server", message: "technical" }), + }), + { + isInitialLoading: false, + hasBlockingError: true, + hasDataInconsistency: false, + } + ); + + assert.deepEqual( + resolveCompanyVerifactuSettingsPageFeedbackState({ + isLoading: false, + isLoadError: false, + hasProfileData: false, + error: null, + }), + { + isInitialLoading: false, + hasBlockingError: false, + hasDataInconsistency: true, + } + ); +}); diff --git a/modules/customer-invoices/src/web/settings/verifactu/utils/resolve-company-verifactu-settings-page-feedback-state.ts b/modules/customer-invoices/src/web/settings/verifactu/utils/resolve-company-verifactu-settings-page-feedback-state.ts new file mode 100644 index 00000000..915d88e3 --- /dev/null +++ b/modules/customer-invoices/src/web/settings/verifactu/utils/resolve-company-verifactu-settings-page-feedback-state.ts @@ -0,0 +1,27 @@ +import { isApiClientError } from "@erp/core/client"; + +export interface CompanyVerifactuSettingsPageFeedbackState { + isInitialLoading: boolean; + hasBlockingError: boolean; + hasDataInconsistency: boolean; +} + +export const resolveCompanyVerifactuSettingsPageFeedbackState = ({ + isLoading, + isLoadError, + hasProfileData, + error, +}: { + isLoading: boolean; + isLoadError: boolean; + hasProfileData: boolean; + error: unknown; +}): CompanyVerifactuSettingsPageFeedbackState => { + const isCancelled = isApiClientError(error) && error.kind === "cancelled"; + + return { + isInitialLoading: isLoading || (isLoadError && isCancelled && !hasProfileData), + hasBlockingError: isLoadError && !isCancelled && !hasProfileData, + hasDataInconsistency: !isLoading && !isLoadError && !hasProfileData, + }; +}; diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-supplier-invoice-domain.mapper.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-supplier-invoice-domain.mapper.ts index 0bd0dd88..81d18b8b 100644 --- a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-supplier-invoice-domain.mapper.ts +++ b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-supplier-invoice-domain.mapper.ts @@ -498,4 +498,3 @@ export class SequelizeSupplierInvoiceDomainMapper extends SequelizeDomainMapper< ); } } -11111111 \ No newline at end of file diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-supplier-invoice-taxes-domain.mapper.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-supplier-invoice-taxes-domain.mapper.ts index 995b7bf5..80d86359 100644 --- a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-supplier-invoice-taxes-domain.mapper.ts +++ b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-supplier-invoice-taxes-domain.mapper.ts @@ -21,7 +21,6 @@ import { Result } from "@repo/rdx-utils"; import { type ISupplierInvoiceCreateProps, InvoiceAmount, - ItemAmount, type SupplierInvoice, SupplierInvoiceTax, } from "../../../../../../domain"; diff --git a/packages/rdx-ui/src/components/editor-page/rdx-editor-foundations.test.tsx b/packages/rdx-ui/src/components/editor-page/rdx-editor-foundations.test.tsx index 52958b36..e9f874b0 100644 --- a/packages/rdx-ui/src/components/editor-page/rdx-editor-foundations.test.tsx +++ b/packages/rdx-ui/src/components/editor-page/rdx-editor-foundations.test.tsx @@ -1,8 +1,10 @@ -ximport assert from "node:assert/strict"; +import assert from "node:assert/strict"; import test from "node:test"; + import { renderToStaticMarkup } from "react-dom/server"; import { RdxFormGrid } from "../form/rdx-form-grid.tsx"; + import { RdxEditorPageLayout } from "./rdx-editor-page-layout.tsx"; import { RdxEditorSection } from "./rdx-editor-section.tsx"; import { RdxEditorStickyFooter } from "./rdx-editor-sticky-footer.tsx"; @@ -113,7 +115,9 @@ test("RdxEditorStickyFooter renderiza regiones presentes sin wrappers vacíos", }); test("RdxEditorStickyFooter omite regiones opcionales vacías", () => { - const html = renderToStaticMarkup(Save} />); + const html = renderToStaticMarkup( + Save} /> + ); assert.match(html, /Save/); assert.doesNotMatch(html, /text-muted-foreground/);