diff --git a/.vscode/settings.json b/.vscode/settings.json index 7b4957cc..6f618606 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -60,5 +60,12 @@ // other vscode settings "[sql]": { "editor.defaultFormatter": "cweijan.vscode-mysql-client2" + }, + "chat.tools.terminal.autoApprove": { + "/^cd /home/rodax/Documentos/uecko-erp/apps/web/src/layout && python3 - <<'PY'\nfrom pathlib import Path\nfiles = \\{\n 'app-layout\\.tsx': '''import type \\* as React from \"react\"\nimport \\{ SidebarInset, SidebarProvider \\} from \"@repo/shadcn-ui/components\"\nimport \\{ Outlet \\} from \"react-router\"\n\nimport \\{ AppMain \\} from \"\\./app-main\"\nimport \\{ AppSidebar \\} from \"\\./app-sidebar\"\nimport \\{ AppTopbar \\} from \"\\./app-topbar\"\n\nexport const AppLayout = \\(\\) => \\{\n return \\(\n \n \n \n \n \n \n \n \n \n \\)\n\\}\n''',\n 'app-main\\.tsx': '''import type \\* as React from \"react\"\n\nimport \\{ cn \\} from \"@repo/shadcn-ui/utils\"\n\ninterface AppMainProps \\{\n children: React\\.ReactNode\n className\\?: string\n\\}\n\nexport const AppMain = \\(\\{ children, className \\}: AppMainProps\\) => \\{\n return \\(\n
\n \\{children\\}\n
\n \\)\n\\}\n''',\n 'app-sidebar\\.config\\.ts': '''import \\{\n BanknoteIcon,\n BarChart3Icon,\n BoxesIcon,\n Building2Icon,\n ClipboardListIcon,\n CoinsIcon,\n CreditCardIcon,\n FileTextIcon,\n LandmarkIcon,\n PackageIcon,\n PercentIcon,\n ReceiptIcon,\n RefreshCcwIcon,\n SettingsIcon,\n ShoppingCartIcon,\n SlidersHorizontalIcon,\n TruckIcon,\n UserCogIcon,\n UsersIcon,\n WarehouseIcon,\n\\} from \"lucide-react\"\nimport type \\{ LucideIcon \\} from \"lucide-react\"\n\nexport interface AppSidebarNavItem \\{\n title: string\n href: string\n icon: LucideIcon\n\\}\n\nexport interface AppSidebarNavSection \\{\n title: string\n items: AppSidebarNavItem\\[\\]\n\\}\n\nexport const appSidebarNavSections: AppSidebarNavSection\\[\\] = \\[\n \\{\n title: \"Ventas\",\n items: \\[\n \\{ title: \"Proformas\", href: \"/proformas\", icon: FileTextIcon \\},\n \\{ title: \"Pedidos de venta\", href: \"/sales-orders\", icon: ShoppingCartIcon \\},\n \\{ title: \"Clientes\", href: \"/customers\", icon: UsersIcon \\},\n \\{ title: \"Facturación\", href: \"/customer-invoices\", icon: ReceiptIcon \\},\n \\{ title: \"Cobros\", href: \"/collections\", icon: CreditCardIcon \\},\n \\{ title: \"Devoluciones\", href: \"/sales-returns\", icon: RefreshCcwIcon \\},\n \\],\n \\},\n \\{\n title: \"Compras\",\n items: \\[\n \\{ title: \"Pedidos de compra\", href: \"/purchase-orders\", icon: ClipboardListIcon \\},\n \\{ title: \"Proveedores\", href: \"/suppliers\", icon: TruckIcon \\},\n \\{ title: \"Facturas de compra\", href: \"/supplier-invoices\", icon: ReceiptIcon \\},\n \\{ title: \"Pagos\", href: \"/payments\", icon: BanknoteIcon \\},\n \\{ title: \"Devoluciones\", href: \"/purchase-returns\", icon: RefreshCcwIcon \\},\n \\],\n \\},\n \\{\n title: \"Inventario\",\n items: \\[\n \\{ title: \"Productos\", href: \"/products\", icon: PackageIcon \\},\n \\{ title: \"Almacenes\", href: \"/warehouses\", icon: WarehouseIcon \\},\n \\{ title: \"Movimientos\", href: \"/stock-movements\", icon: BoxesIcon \\},\n \\{ title: \"Ajustes de stock\", href: \"/stock-adjustments\", icon: SlidersHorizontalIcon \\},\n \\{ title: \"Categorías\", href: \"/product-categories\", icon: ClipboardListIcon \\},\n \\],\n \\},\n \\{\n title: \"Contabilidad\",\n items: \\[\n \\{ title: \"Plan contable\", href: \"/accounting/chart\", icon: LandmarkIcon \\},\n \\{ title: \"Asientos contables\", href: \"/accounting/entries\", icon: FileTextIcon \\},\n \\{ title: \"Diario\", href: \"/accounting/journal\", icon: ClipboardListIcon \\},\n \\{ title: \"Informes financieros\", href: \"/accounting/reports\", icon: BarChart3Icon \\},\n \\],\n \\},\n \\{\n title: \"Configuración\",\n items: \\[\n \\{ title: \"Usuarios y roles\", href: \"/settings/users\", icon: UserCogIcon \\},\n \\{ title: \"Impuestos\", href: \"/settings/taxes\", icon: PercentIcon \\},\n \\{ title: \"Monedas\", href: \"/settings/currencies\", icon: CoinsIcon \\},\n \\{ title: \"Empresas\", href: \"/settings/companies\", icon: Building2Icon \\},\n \\{ title: \"Parámetros\", href: \"/settings\", icon: SettingsIcon \\},\n \\],\n \\},\n\\]\n''',\n 'app-sidebar\\.tsx': '''import type \\* as React from \"react\"\nimport \\{\n Sidebar,\n SidebarContent,\n SidebarFooter,\n SidebarHeader,\n SidebarRail,\n SidebarTrigger,\n\\} from \"@repo/shadcn-ui/components\"\nimport \\{ cn \\} from \"@repo/shadcn-ui/utils\"\n\nimport \\{ AppSidebarNav \\} from \"\\./app-sidebar-nav\"\nimport \\{ appSidebarNavSections \\} from \"\\./app-sidebar\\.config\"\n\ninterface AppSidebarProps \\{\n className\\?: string\n\\}\n\nexport const AppSidebar = \\(\\{ className \\}: AppSidebarProps\\) => \\{\n return \\(\n \n \n
\n
\n ERP\n
\n\n
\n

\n Mi Empresa\n

\n
\n\n \n
\n
\n\n \n \n \n\n \n
\n
\n AM\n
\n
\n

Ana Martínez

\n

\n Administrador\n

\n
\n
\n
\n\n \n \n \\)\n\\}\n''',\n 'app-sidebar-nav\\.tsx': '''import type \\{ AppSidebarNavSection \\} from \"\\./app-sidebar\\.config\"\nimport \\{ NavLink \\} from \"react-router\"\nimport \\{\n SidebarGroup,\n SidebarGroupContent,\n SidebarGroupLabel,\n SidebarMenu,\n SidebarMenuButton,\n SidebarMenuItem,\n\\} from \"@repo/shadcn-ui/components\"\nimport \\{ cn \\} from \"@repo/shadcn-ui/utils\"\n\ninterface AppSidebarNavProps \\{\n sections: AppSidebarNavSection\\[\\]\n\\}\n\nexport const AppSidebarNav = \\(\\{ sections \\}: AppSidebarNavProps\\) => \\{\n return \\(\n \n \\)\n\\}\n''',\n 'app-topbar\\.tsx': '''import \\{\n BellIcon,\n ChevronDownIcon,\n MessageSquareIcon,\n SearchIcon,\n\\} from \"lucide-react\"\nimport \\{\n Button,\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n Input,\n SidebarTrigger,\n\\} from \"@repo/shadcn-ui/components\"\n\nexport const AppTopbar = \\(\\) => \\{\n return \\(\n
\n
\n \n\n
\n \n \n \n
\n
\n\n
\n \n \n \n 3\n \n \n\n \n \n \n 7\n \n \n\n \n \n \n \n AM\n \n \n \n Ana Martínez\n \n \n Administrador\n \n \n \n \n \n\n \n Mi cuenta\n \n Perfil\n Preferencias\n Cerrar sesión\n \n \n
\n
\n \\)\n\\}\n'''\n\\}\nfor name, content in files\\.items\\(\\):\n Path\\(name\\)\\.write_text\\(content, encoding='utf-8'\\)\nPY$/": { + "approve": true, + "matchCommandLine": true + }, + "pnpm": true } // <- your root font size here } diff --git a/apps/server/src/lib/modules/module-loader.ts b/apps/server/src/lib/modules/module-loader.ts index ebcdf767..8f6a2b40 100644 --- a/apps/server/src/lib/modules/module-loader.ts +++ b/apps/server/src/lib/modules/module-loader.ts @@ -208,7 +208,7 @@ function validateModuleServices(moduleName: string, services: Record = {}; * Registra un objeto de servicio (API) bajo un nombre. */ export function registerService(name: string, api: unknown) { + console.debug(`Registering service: ${name}`); if (services[name]) { throw new Error(`❌ Servicio "${name}" ya fue registrado.`); } diff --git a/modules/catalogs/src/api/application/index.ts b/modules/catalogs/src/api/application/index.ts index 7b68c32a..1702efcf 100644 --- a/modules/catalogs/src/api/application/index.ts +++ b/modules/catalogs/src/api/application/index.ts @@ -1,5 +1,4 @@ export * from "./payment-methods"; export * from "./payment-terms"; -export * from "./services"; export * from "./tax-definitions"; export * from "./tax-regimes"; diff --git a/modules/catalogs/src/api/application/payment-methods/index.ts b/modules/catalogs/src/api/application/payment-methods/index.ts index b68ea0dc..77d7c040 100644 --- a/modules/catalogs/src/api/application/payment-methods/index.ts +++ b/modules/catalogs/src/api/application/payment-methods/index.ts @@ -1,6 +1,7 @@ export * from "./di"; export * from "./mappers"; export * from "./models"; +export * from "./public"; export * from "./repositories"; export * from "./services"; export * from "./snapshot-builders"; diff --git a/modules/catalogs/src/api/application/payment-methods/public/errors/index.ts b/modules/catalogs/src/api/application/payment-methods/public/errors/index.ts new file mode 100644 index 00000000..7fedd188 --- /dev/null +++ b/modules/catalogs/src/api/application/payment-methods/public/errors/index.ts @@ -0,0 +1 @@ +export * from './payment-method-not-active.error'; diff --git a/modules/catalogs/src/api/application/payment-methods/public/errors/payment-method-not-active.error.ts b/modules/catalogs/src/api/application/payment-methods/public/errors/payment-method-not-active.error.ts new file mode 100644 index 00000000..3025dd0c --- /dev/null +++ b/modules/catalogs/src/api/application/payment-methods/public/errors/payment-method-not-active.error.ts @@ -0,0 +1,9 @@ +import { DomainError, UniqueID } from "@repo/rdx-ddd"; + +export class PaymentMethodNotActiveError extends DomainError { + public readonly code = "PAYMENT_METHOD_NOT_ACTIVE" as const; + + public constructor(public readonly id: UniqueID) { + super(`Payment method with id ${id.toString()} is not active`); + } +} diff --git a/modules/catalogs/src/api/application/payment-methods/public/index.ts b/modules/catalogs/src/api/application/payment-methods/public/index.ts new file mode 100644 index 00000000..0035998c --- /dev/null +++ b/modules/catalogs/src/api/application/payment-methods/public/index.ts @@ -0,0 +1,4 @@ +export * from "./mappers"; +export * from "./models/"; +export * from "./services"; +export * from "./services/"; diff --git a/modules/catalogs/src/api/application/payment-methods/public/mappers/index.ts b/modules/catalogs/src/api/application/payment-methods/public/mappers/index.ts new file mode 100644 index 00000000..d7046b81 --- /dev/null +++ b/modules/catalogs/src/api/application/payment-methods/public/mappers/index.ts @@ -0,0 +1 @@ +export * from './payment-method-public-model.mapper'; diff --git a/modules/catalogs/src/api/application/payment-methods/public/mappers/payment-method-public-model.mapper.ts b/modules/catalogs/src/api/application/payment-methods/public/mappers/payment-method-public-model.mapper.ts new file mode 100644 index 00000000..e0f25e01 --- /dev/null +++ b/modules/catalogs/src/api/application/payment-methods/public/mappers/payment-method-public-model.mapper.ts @@ -0,0 +1,18 @@ + +import type { + PaymentMethodPublicModel +} from "../models"; +import { PaymentMethod } from '../../../../domain'; + +export class PaymentMethodPublicModelMapper { + public toPublicModel(paymentMethod: PaymentMethod): PaymentMethodPublicModel { + return { + id: paymentMethod.id, + companyId: paymentMethod.companyId, + name: paymentMethod.name.toPrimitive(), + description: paymentMethod.description.map((value) => value.toPrimitive()), + isActive: paymentMethod.isActive, + }; + } + +} diff --git a/modules/catalogs/src/api/application/payment-methods/public/models/index.ts b/modules/catalogs/src/api/application/payment-methods/public/models/index.ts new file mode 100644 index 00000000..45d01dad --- /dev/null +++ b/modules/catalogs/src/api/application/payment-methods/public/models/index.ts @@ -0,0 +1 @@ +export * from './payment-method-public.model'; diff --git a/modules/catalogs/src/api/application/payment-methods/public/models/payment-method-public.model.ts b/modules/catalogs/src/api/application/payment-methods/public/models/payment-method-public.model.ts new file mode 100644 index 00000000..2fa25788 --- /dev/null +++ b/modules/catalogs/src/api/application/payment-methods/public/models/payment-method-public.model.ts @@ -0,0 +1,14 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Maybe } from "@repo/rdx-utils"; + +export interface PaymentMethodPublicModel { + id: UniqueID; + companyId: UniqueID; + + name: string; + description: Maybe; + + isActive: boolean; + + +} diff --git a/modules/catalogs/src/api/application/payment-methods/public/services/index.ts b/modules/catalogs/src/api/application/payment-methods/public/services/index.ts new file mode 100644 index 00000000..928428f5 --- /dev/null +++ b/modules/catalogs/src/api/application/payment-methods/public/services/index.ts @@ -0,0 +1,2 @@ +export * from './payment-method-public-finder.interface'; +export * from './payment-method-public-finder'; diff --git a/modules/catalogs/src/api/application/payment-methods/public/services/payment-method-public-finder.interface.ts b/modules/catalogs/src/api/application/payment-methods/public/services/payment-method-public-finder.interface.ts new file mode 100644 index 00000000..52c9fa06 --- /dev/null +++ b/modules/catalogs/src/api/application/payment-methods/public/services/payment-method-public-finder.interface.ts @@ -0,0 +1,24 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Maybe, Result } from "@repo/rdx-utils"; + +import type { PaymentMethodPublicModel } from "../models/payment-method-public.model"; + +export interface FindPaymentMethodByIdInCompanyParams { + companyId: UniqueID; + id: UniqueID; + transaction?: unknown; +} + +export interface IPaymentMethodPublicFinder { + existsByIdInCompany( + params: FindPaymentMethodByIdInCompanyParams, + ): Promise>; + + getByIdInCompany( + params: FindPaymentMethodByIdInCompanyParams, + ): Promise>; + + findByIdInCompany( + params: FindPaymentMethodByIdInCompanyParams, + ): Promise, Error>>; +} \ No newline at end of file diff --git a/modules/catalogs/src/api/application/payment-methods/public/services/payment-method-public-finder.ts b/modules/catalogs/src/api/application/payment-methods/public/services/payment-method-public-finder.ts new file mode 100644 index 00000000..e9cddc78 --- /dev/null +++ b/modules/catalogs/src/api/application/payment-methods/public/services/payment-method-public-finder.ts @@ -0,0 +1,71 @@ +import { Maybe, Result } from "@repo/rdx-utils"; + +import type { IPaymentMethodRepository } from "../../repositories"; +import type { PaymentMethodPublicModelMapper } from "../mappers/payment-method-public-model.mapper"; +import type { PaymentMethodPublicModel } from "../models/payment-method-public.model"; +import type { + FindPaymentMethodByIdInCompanyParams, + IPaymentMethodPublicFinder, +} from "./payment-method-public-finder.interface"; + +export class PaymentMethodPublicFinder implements IPaymentMethodPublicFinder { + public constructor( + private readonly deps: { + repository: IPaymentMethodRepository; + mapper: PaymentMethodPublicModelMapper; + }, + ) {} + + public async existsByIdInCompany( + params: FindPaymentMethodByIdInCompanyParams, + ): Promise> { + const result = await this.deps.repository.existsByIdInCompany( + params.companyId, + params.id, + params.transaction, + ); + + if (result.isFailure) { + return Result.fail(result.error); + } + + return Result.ok(result.data); + } + + public async getByIdInCompany( + params: FindPaymentMethodByIdInCompanyParams, + ): Promise> { + const result = await this.deps.repository.getByIdInCompany( + params.companyId, + params.id, + params.transaction, + ); + + if (result.isFailure) { + return Result.fail(result.error); + } + + return Result.ok(this.deps.mapper.toPublicModel(result.data)); + } + + public async findByIdInCompany( + params: FindPaymentMethodByIdInCompanyParams, + ): Promise, Error>> { + const result = await this.deps.repository.findByIdInCompany( + params.companyId, + params.id, + params.transaction, + ); + + if (result.isFailure) { + return Result.fail(result.error); + } + + return Result.ok( + result.data.match( + (paymentMethod) => Maybe.some(this.deps.mapper.toPublicModel(paymentMethod)), + () => Maybe.none(), + ), + ); + } +} \ No newline at end of file diff --git a/modules/catalogs/src/api/application/payment-methods/repositories/payment-method-repository.interface.ts b/modules/catalogs/src/api/application/payment-methods/repositories/payment-method-repository.interface.ts index 946b9998..c2726f05 100644 --- a/modules/catalogs/src/api/application/payment-methods/repositories/payment-method-repository.interface.ts +++ b/modules/catalogs/src/api/application/payment-methods/repositories/payment-method-repository.interface.ts @@ -1,6 +1,6 @@ import type { Criteria } from "@repo/rdx-criteria/server"; import type { UniqueID } from "@repo/rdx-ddd"; -import type { Collection, Result } from "@repo/rdx-utils"; +import type { Collection, Maybe, Result } from "@repo/rdx-utils"; import type { PaymentMethod } from "../../../domain"; import type { PaymentMethodSummary } from "../models"; @@ -18,6 +18,11 @@ export interface IPaymentMethodRepository { id: UniqueID, transaction?: unknown ): Promise>; + findByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction?: unknown + ): Promise, Error>>; findByCriteriaInCompany( companyId: UniqueID, criteria: Criteria, diff --git a/modules/catalogs/src/api/application/payment-methods/services/payment-method-finder.ts b/modules/catalogs/src/api/application/payment-methods/services/payment-method-finder.ts index 43fb3703..dba7cec9 100644 --- a/modules/catalogs/src/api/application/payment-methods/services/payment-method-finder.ts +++ b/modules/catalogs/src/api/application/payment-methods/services/payment-method-finder.ts @@ -9,13 +9,13 @@ import type { IPaymentMethodRepository } from "../repositories"; export interface IPaymentMethodFinder { findPaymentMethodById( companyId: UniqueID, - invoiceId: UniqueID, + paymentMethodId: UniqueID, transaction?: unknown ): Promise>; paymentmethodExists( companyId: UniqueID, - invoiceId: UniqueID, + paymentMethodId: UniqueID, transaction?: unknown ): Promise>; @@ -31,18 +31,18 @@ export class PaymentMethodFinder implements IPaymentMethodFinder { async findPaymentMethodById( companyId: UniqueID, - paymentmethodId: UniqueID, + paymentMethodId: UniqueID, transaction?: unknown ): Promise> { - return this.repository.getByIdInCompany(companyId, paymentmethodId, transaction); + return this.repository.getByIdInCompany(companyId, paymentMethodId, transaction); } async paymentmethodExists( companyId: UniqueID, - paymentmethodId: UniqueID, + paymentMethodId: UniqueID, transaction?: unknown ): Promise> { - return this.repository.existsByIdInCompany(companyId, paymentmethodId, transaction); + return this.repository.existsByIdInCompany(companyId, paymentMethodId, transaction); } async findPaymentMethodsByCriteria( diff --git a/modules/catalogs/src/api/application/services/catalog-public-services.interface.ts b/modules/catalogs/src/api/application/services/catalog-public-services.interface.ts deleted file mode 100644 index aac260c3..00000000 --- a/modules/catalogs/src/api/application/services/catalog-public-services.interface.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { UniqueID } from "@repo/rdx-ddd"; - -import type { IPaymentMethodPublicServices } from "../payment-methods"; -import type { IPaymentTermPublicServices } from "../payment-terms"; -import type { ITaxRegimePublicServices } from "../tax-regimes"; - -export type { IPaymentMethodPublicServices } from "../payment-methods"; -export type { IPaymentTermPublicServices } from "../payment-terms"; -export type { ITaxRegimePublicServices } from "../tax-regimes"; - -export interface ICatalogServicesContext { - transaction: unknown; - companyId: UniqueID; -} - -export interface ICatalogPublicServices { - paymentMethods: IPaymentMethodPublicServices; - paymentTerms: IPaymentTermPublicServices; - taxRegimes: ITaxRegimePublicServices; -} diff --git a/modules/catalogs/src/api/application/services/index.ts b/modules/catalogs/src/api/application/services/index.ts deleted file mode 100644 index 81746ced..00000000 --- a/modules/catalogs/src/api/application/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './catalog-public-services.interface'; diff --git a/modules/catalogs/src/api/application/tax-definitions/index.ts b/modules/catalogs/src/api/application/tax-definitions/index.ts index f9c2a070..b6a7cf59 100644 --- a/modules/catalogs/src/api/application/tax-definitions/index.ts +++ b/modules/catalogs/src/api/application/tax-definitions/index.ts @@ -1,5 +1,6 @@ export * from "./mappers"; export * from "./models"; +export * from "./public"; export * from "./repositories"; export * from "./services"; export * from "./snapshot-builders"; diff --git a/modules/catalogs/src/api/application/tax-definitions/public/errors/index.ts b/modules/catalogs/src/api/application/tax-definitions/public/errors/index.ts new file mode 100644 index 00000000..4d07a60f --- /dev/null +++ b/modules/catalogs/src/api/application/tax-definitions/public/errors/index.ts @@ -0,0 +1 @@ +export * from './tax-definitions-not-found.error'; diff --git a/modules/catalogs/src/api/application/tax-definitions/public/errors/tax-definitions-not-found.error.ts b/modules/catalogs/src/api/application/tax-definitions/public/errors/tax-definitions-not-found.error.ts new file mode 100644 index 00000000..49679ccf --- /dev/null +++ b/modules/catalogs/src/api/application/tax-definitions/public/errors/tax-definitions-not-found.error.ts @@ -0,0 +1,10 @@ +import { DomainError } from "@repo/rdx-ddd"; +import type { Collection } from "@repo/rdx-utils"; + +export class TaxDefinitionsNotFoundError extends DomainError { + public readonly code = "TAX_DEFINITIONS_NOT_FOUND" as const; + + public constructor(public readonly missingCodes: Collection) { + super(`Tax definitions not found for codes: ${missingCodes.getAll().join(", ")}`); + } +} diff --git a/modules/catalogs/src/api/application/tax-definitions/public/index.ts b/modules/catalogs/src/api/application/tax-definitions/public/index.ts new file mode 100644 index 00000000..4e9a8fb1 --- /dev/null +++ b/modules/catalogs/src/api/application/tax-definitions/public/index.ts @@ -0,0 +1,5 @@ +export * from "./errors/"; +export * from "./mappers"; +export * from "./models/"; +export * from "./services"; +export * from "./services/"; diff --git a/modules/catalogs/src/api/application/tax-definitions/public/mappers/index.ts b/modules/catalogs/src/api/application/tax-definitions/public/mappers/index.ts new file mode 100644 index 00000000..bb57fdb7 --- /dev/null +++ b/modules/catalogs/src/api/application/tax-definitions/public/mappers/index.ts @@ -0,0 +1 @@ +export * from './tax-definition-public-model.mapper'; diff --git a/modules/catalogs/src/api/application/tax-definitions/public/mappers/tax-definition-public-model.mapper.ts b/modules/catalogs/src/api/application/tax-definitions/public/mappers/tax-definition-public-model.mapper.ts new file mode 100644 index 00000000..4da9fae6 --- /dev/null +++ b/modules/catalogs/src/api/application/tax-definitions/public/mappers/tax-definition-public-model.mapper.ts @@ -0,0 +1,80 @@ +import { Collection, Maybe } from "@repo/rdx-utils"; + +import type { TaxDefinition } from "../../../../domain/tax-definitions"; +import type { + TaxDefinitionPublicCalculationBehavior, + TaxDefinitionPublicFamily, + TaxDefinitionPublicModel, + TaxDefinitionPublicScope, +} from "../models/tax-definition-public.model"; + +export class TaxDefinitionPublicModelMapper { + public toPublicModel(taxDefinition: TaxDefinition): TaxDefinitionPublicModel { + return { + id: taxDefinition.id, + companyId: Maybe.some(taxDefinition.companyId), + code: taxDefinition.code.toPrimitive(), + name: taxDefinition.name.toPrimitive(), + description: taxDefinition.description.map((value) => value.toPrimitive()), + rate: taxDefinition.rate, + taxFamily: this.mapTaxFamily(taxDefinition.taxFamily.toPrimitive()), + calculationBehavior: this.mapCalculationBehavior( + taxDefinition.calculationBehavior.toPrimitive() + ), + taxScope: this.mapTaxScope(taxDefinition.taxScope.toPrimitive()), + jurisdictionCountryCode: taxDefinition.jurisdictionCountryCode, + jurisdictionRegionCode: taxDefinition.jurisdictionRegionCode, + invoiceNote: taxDefinition.invoiceNote.map((value) => value.toPrimitive()), + allowedSurchargeCodes: new Collection( + taxDefinition.allowedSurchargeCodes.match( + (codes) => codes.map((code) => code.toPrimitive()), + () => [] + ) + ), + isSystem: taxDefinition.isSystem, + isActive: taxDefinition.isActive, + validFrom: taxDefinition.validFrom, + validTo: taxDefinition.validTo, + }; + } + + private mapTaxFamily(value: string): TaxDefinitionPublicFamily { + switch (value) { + case "iva": + case "igic": + case "ipsi": + return value; + case "equivalence_surcharge": + return "surcharge"; + case "withholding": + return "retention"; + default: + throw new Error(`Unsupported tax family for public model: ${value}`); + } + } + + private mapCalculationBehavior(value: string): TaxDefinitionPublicCalculationBehavior { + switch (value) { + case "additive": + case "subtractive": + return value; + default: + throw new Error(`Unsupported calculation behavior for public model: ${value}`); + } + } + + private mapTaxScope(value: string): TaxDefinitionPublicScope { + switch (value) { + case "domestic": + return "sales"; + case "intra_eu": + case "import": + return "purchases"; + case "export": + case "international": + return "both"; + default: + throw new Error(`Unsupported tax scope for public model: ${value}`); + } + } +} diff --git a/modules/catalogs/src/api/application/tax-definitions/public/models/index.ts b/modules/catalogs/src/api/application/tax-definitions/public/models/index.ts new file mode 100644 index 00000000..fa5957bf --- /dev/null +++ b/modules/catalogs/src/api/application/tax-definitions/public/models/index.ts @@ -0,0 +1 @@ +export * from './tax-definition-public.model'; diff --git a/modules/catalogs/src/api/application/tax-definitions/public/models/tax-definition-public.model.ts b/modules/catalogs/src/api/application/tax-definitions/public/models/tax-definition-public.model.ts new file mode 100644 index 00000000..ec31c928 --- /dev/null +++ b/modules/catalogs/src/api/application/tax-definitions/public/models/tax-definition-public.model.ts @@ -0,0 +1,72 @@ +import type { + CountryCode, + CountryRegionCode, Percentage, UniqueID, + UtcDate +} from "@repo/rdx-ddd"; +import type { Collection, Maybe } from "@repo/rdx-utils"; + +/** + * Familia fiscal pública expuesta por `catalogs`. + * + * No se expone el Value Object interno de dominio para evitar acoplar + * consumidores externos a las invariantes privadas de `tax-definitions`. + */ +export type TaxDefinitionPublicFamily = "iva" | "igic" | "ipsi" | "surcharge" | "retention"; + +/** + * Comportamiento de cálculo público de un impuesto. + * + * `additive` suma al total. + * `subtractive` resta del total, por ejemplo retenciones. + */ +export type TaxDefinitionPublicCalculationBehavior = "additive" | "subtractive"; + +/** + * Ámbito público de aplicación fiscal. + */ +export type TaxDefinitionPublicScope = "sales" | "purchases" | "both"; + +/** + * Modelo público de lectura expuesto por el módulo `catalogs`. + * + * Es un contrato backend entre módulos, no un DTO HTTP y no una entidad + * de dominio. Puede usar Value Objects comunes del ERP, pero no debe + * exponer Value Objects internos de `tax-definitions`. + */ +export interface TaxDefinitionPublicModel { + id: UniqueID; + + /** + * `none` si la definición es global/sistema. + * `some(companyId)` si la definición está sobrescrita para una empresa. + */ + companyId: Maybe; + + code: string; + name: string; + description: Maybe; + + rate: Percentage; + + taxFamily: TaxDefinitionPublicFamily; + calculationBehavior: TaxDefinitionPublicCalculationBehavior; + taxScope: TaxDefinitionPublicScope; + + jurisdictionCountryCode: CountryCode; + jurisdictionRegionCode: Maybe; + + invoiceNote: Maybe; + + /** + * Códigos de recargo compatibles con esta definición. + * + * Colección vacía => no permite recargos. + */ + allowedSurchargeCodes: Collection; + + isSystem: boolean; + isActive: boolean; + + validFrom: Maybe; + validTo: Maybe; +} diff --git a/modules/catalogs/src/api/application/tax-definitions/public/services/index.ts b/modules/catalogs/src/api/application/tax-definitions/public/services/index.ts new file mode 100644 index 00000000..c78c790b --- /dev/null +++ b/modules/catalogs/src/api/application/tax-definitions/public/services/index.ts @@ -0,0 +1,2 @@ +export * from './tax-definition-public-finder.interface'; +export * from './tax-definition-public-finder'; diff --git a/modules/catalogs/src/api/application/tax-definitions/public/services/tax-definition-public-finder.interface.ts b/modules/catalogs/src/api/application/tax-definitions/public/services/tax-definition-public-finder.interface.ts new file mode 100644 index 00000000..01227d04 --- /dev/null +++ b/modules/catalogs/src/api/application/tax-definitions/public/services/tax-definition-public-finder.interface.ts @@ -0,0 +1,30 @@ +import type { UniqueID, UtcDate } from "@repo/rdx-ddd"; +import type { Collection, Maybe, Result } from "@repo/rdx-utils"; + +import type { TaxDefinitionPublicModel } from "../models/tax-definition-public.model"; + +export interface FindActiveTaxDefinitionByCodeParams { + companyId: UniqueID; + code: string; + atDate: UtcDate; +} + +export interface FindActiveTaxDefinitionsByCodesParams { + companyId: UniqueID; + codes: Collection; + atDate: UtcDate; +} + +export interface ITaxDefinitionPublicFinder { + findActiveByCode( + params: FindActiveTaxDefinitionByCodeParams + ): Promise, Error>>; + + findActiveByCodes( + params: FindActiveTaxDefinitionsByCodesParams + ): Promise, Error>>; + + ensureActiveByCodes( + params: FindActiveTaxDefinitionsByCodesParams + ): Promise, Error>>; +} diff --git a/modules/catalogs/src/api/application/tax-definitions/public/services/tax-definition-public-finder.ts b/modules/catalogs/src/api/application/tax-definitions/public/services/tax-definition-public-finder.ts new file mode 100644 index 00000000..5c4576d1 --- /dev/null +++ b/modules/catalogs/src/api/application/tax-definitions/public/services/tax-definition-public-finder.ts @@ -0,0 +1,64 @@ +import { Collection, Maybe, Result } from "@repo/rdx-utils"; + +import type { ITaxDefinitionRepository } from "../../repositories"; +import { TaxDefinitionsNotFoundError } from "../errors/tax-definitions-not-found.error"; +import type { TaxDefinitionPublicModelMapper } from "../mappers/tax-definition-public-model.mapper"; +import type { + FindActiveTaxDefinitionByCodeParams, + FindActiveTaxDefinitionsByCodesParams, + ITaxDefinitionPublicFinder, +} from "./tax-definition-public-finder.interface"; +import { TaxDefinitionPublicModel } from '../models'; + +export class TaxDefinitionPublicFinder implements ITaxDefinitionPublicFinder { + public constructor( + private readonly deps: { + repository: ITaxDefinitionRepository; + mapper: TaxDefinitionPublicModelMapper; + } + ) {} + + public async findActiveByCode(params: FindActiveTaxDefinitionByCodeParams): Promise, Error>> { + const result = await this.deps.repository.findActiveByCodeInCompany(params); + + if (result.isFailure) { + return Result.fail(result.error); + } + + return Result.ok(result.data.map((taxDefinition) => this.deps.mapper.toPublicModel(taxDefinition))); + } + + public async findActiveByCodes(params: FindActiveTaxDefinitionsByCodesParams): Promise, Error>> { + const result = await this.deps.repository.findActiveByCodesInCompany(params); + + if (result.isFailure) { + return Result.fail(result.error); + } + + return Result.ok(new Collection(result.data.map((taxDefinition) => this.deps.mapper.toPublicModel(taxDefinition)))); + } + + public async ensureActiveByCodes(params: FindActiveTaxDefinitionsByCodesParams):Promise, Error>> { + const result = await this.findActiveByCodes(params); + + if (result.isFailure) { + return Result.fail(result.error); + } + + const requestedCodes = Array.from( + new Set(params.codes.getAll().map((code) => this.normalizeCode(code))) + ); + const foundCodes = new Set(result.data.getAll().map((taxDefinition) => taxDefinition.code)); + const missingCodes = requestedCodes.filter((code) => !foundCodes.has(code)); + + if (missingCodes.length > 0) { + return Result.fail(new TaxDefinitionsNotFoundError(new Collection(missingCodes))); + } + + return result; + } + + private normalizeCode(code: string): string { + return code.trim().toLowerCase(); + } +} diff --git a/modules/catalogs/src/api/application/tax-definitions/repositories/tax-definition-repository.interface.ts b/modules/catalogs/src/api/application/tax-definitions/repositories/tax-definition-repository.interface.ts index 389294ba..c239ab91 100644 --- a/modules/catalogs/src/api/application/tax-definitions/repositories/tax-definition-repository.interface.ts +++ b/modules/catalogs/src/api/application/tax-definitions/repositories/tax-definition-repository.interface.ts @@ -1,6 +1,6 @@ import type { Criteria } from "@repo/rdx-criteria/server"; -import type { UniqueID } from "@repo/rdx-ddd"; -import type { Collection, Result } from "@repo/rdx-utils"; +import type { UniqueID, UtcDate } from "@repo/rdx-ddd"; +import type { Collection, Maybe, Result } from "@repo/rdx-utils"; import type { TaxDefinition, TaxDefinitionCode } from "../../../domain"; import type { TaxDefinitionSummary } from "../../tax-definitions/models"; @@ -32,6 +32,20 @@ export interface ITaxDefinitionRepository { transaction?: unknown ): Promise>; + findActiveByCodeInCompany(params: { + companyId: UniqueID; + code: string; + atDate: UtcDate; + transaction?: unknown; + }): Promise, Error>>; + + findActiveByCodesInCompany(params: { + companyId: UniqueID; + codes: Collection; + atDate: UtcDate; + transaction?: unknown; + }): Promise, Error>>; + findByCriteriaInCompany( companyId: UniqueID, criteria: Criteria, diff --git a/modules/catalogs/src/api/application/tax-definitions/services/index.ts b/modules/catalogs/src/api/application/tax-definitions/services/index.ts index fa60b209..cb32ad1e 100644 --- a/modules/catalogs/src/api/application/tax-definitions/services/index.ts +++ b/modules/catalogs/src/api/application/tax-definitions/services/index.ts @@ -1,5 +1,6 @@ export * from "./tax-definition-creator.service"; export * from "./tax-definition-deleter.service"; export * from "./tax-definition-finder.service"; +export * from "./tax-definition-public-services"; export * from "./tax-definition-status-changer.service"; export * from "./tax-definition-updater.service"; diff --git a/modules/catalogs/src/api/application/tax-definitions/services/tax-definition-public-services.ts b/modules/catalogs/src/api/application/tax-definitions/services/tax-definition-public-services.ts new file mode 100644 index 00000000..17974738 --- /dev/null +++ b/modules/catalogs/src/api/application/tax-definitions/services/tax-definition-public-services.ts @@ -0,0 +1,11 @@ +import type { ITaxDefinitionPublicFinder } from "../public"; + +export interface ITaxDefinitionPublicServices { + finder: ITaxDefinitionPublicFinder; +} + +export const buildTaxDefinitionsPublicServices = ( + finder: ITaxDefinitionPublicFinder +): ITaxDefinitionPublicServices => ({ + finder, +}); diff --git a/modules/catalogs/src/api/application/tax-definitions/snapshot-builders/full/full-tax-definition-snapshot.builder.ts b/modules/catalogs/src/api/application/tax-definitions/snapshot-builders/full/full-tax-definition-snapshot.builder.ts index 1d058f4a..4570e8e3 100644 --- a/modules/catalogs/src/api/application/tax-definitions/snapshot-builders/full/full-tax-definition-snapshot.builder.ts +++ b/modules/catalogs/src/api/application/tax-definitions/snapshot-builders/full/full-tax-definition-snapshot.builder.ts @@ -1,4 +1,3 @@ -import type { TaxDefinitionDetailDTO } from "@erp/catalogs/common"; import type { ISnapshotBuilder } from "@erp/core/api"; import type { TaxDefinitionDetail } from "../../models"; diff --git a/modules/catalogs/src/api/application/tax-regimes/index.ts b/modules/catalogs/src/api/application/tax-regimes/index.ts index f9c2a070..b6a7cf59 100644 --- a/modules/catalogs/src/api/application/tax-regimes/index.ts +++ b/modules/catalogs/src/api/application/tax-regimes/index.ts @@ -1,5 +1,6 @@ export * from "./mappers"; export * from "./models"; +export * from "./public"; export * from "./repositories"; export * from "./services"; export * from "./snapshot-builders"; diff --git a/modules/catalogs/src/api/application/tax-regimes/public/errors/index.ts b/modules/catalogs/src/api/application/tax-regimes/public/errors/index.ts new file mode 100644 index 00000000..515a81c3 --- /dev/null +++ b/modules/catalogs/src/api/application/tax-regimes/public/errors/index.ts @@ -0,0 +1 @@ +export * from './tax-regime-not-found.error'; diff --git a/modules/catalogs/src/api/application/tax-regimes/public/errors/tax-regime-not-found.error.ts b/modules/catalogs/src/api/application/tax-regimes/public/errors/tax-regime-not-found.error.ts new file mode 100644 index 00000000..84633786 --- /dev/null +++ b/modules/catalogs/src/api/application/tax-regimes/public/errors/tax-regime-not-found.error.ts @@ -0,0 +1,10 @@ +import { DomainError } from "@repo/rdx-ddd"; +import type { Collection } from "@repo/rdx-utils"; + +export class TaxRegimesNotFoundError extends DomainError { + public readonly code = "TAX_REGIME_NOT_FOUND" as const; + + public constructor(public readonly missingCodes: Collection) { + super(`Tax regime not found for codes: ${missingCodes.getAll().join(", ")}`); + } +} diff --git a/modules/catalogs/src/api/application/tax-regimes/public/index.ts b/modules/catalogs/src/api/application/tax-regimes/public/index.ts new file mode 100644 index 00000000..a3edecb3 --- /dev/null +++ b/modules/catalogs/src/api/application/tax-regimes/public/index.ts @@ -0,0 +1,14 @@ +export { TaxRegimesNotFoundError } from "./errors/"; +export { TaxRegimePublicModelMapper } from "./mappers"; +export type { + TaxRegimePublicCalculationBehavior, + TaxRegimePublicFamily, + TaxRegimePublicModel, + TaxRegimePublicScope, +} from "./models/"; +export type { + FindActiveTaxRegimeByCodeParams, + FindActiveTaxRegimesByCodesParams, + ITaxRegimePublicFinder, +} from "./services"; +export { TaxRegimePublicFinder } from "./services/"; diff --git a/modules/catalogs/src/api/application/tax-regimes/public/mappers/index.ts b/modules/catalogs/src/api/application/tax-regimes/public/mappers/index.ts new file mode 100644 index 00000000..6b8ad82f --- /dev/null +++ b/modules/catalogs/src/api/application/tax-regimes/public/mappers/index.ts @@ -0,0 +1 @@ +export * from './tax-regime-public-model.mapper'; diff --git a/modules/catalogs/src/api/application/tax-regimes/public/mappers/tax-regime-public-model.mapper.ts b/modules/catalogs/src/api/application/tax-regimes/public/mappers/tax-regime-public-model.mapper.ts new file mode 100644 index 00000000..50ff84c1 --- /dev/null +++ b/modules/catalogs/src/api/application/tax-regimes/public/mappers/tax-regime-public-model.mapper.ts @@ -0,0 +1,20 @@ + +import type { TaxRegime } from "../../../../domain/tax-regimes"; +import type { + TaxRegimePublicModel +} from "../models/tax-regime-public.model"; + +export class TaxRegimePublicModelMapper { + public toPublicModel(TaxRegime: TaxRegime): TaxRegimePublicModel { + return { + id: TaxRegime.id, + companyId: TaxRegime.companyId, + code: TaxRegime.code.toPrimitive(), + description: TaxRegime.description.toPrimitive(), + isSystem: TaxRegime.isSystem, + isActive: TaxRegime.isActive, + }; + } + + +} diff --git a/modules/catalogs/src/api/application/tax-regimes/public/models/index.ts b/modules/catalogs/src/api/application/tax-regimes/public/models/index.ts new file mode 100644 index 00000000..cca6b57e --- /dev/null +++ b/modules/catalogs/src/api/application/tax-regimes/public/models/index.ts @@ -0,0 +1 @@ +export * from './tax-regime-public.model'; diff --git a/modules/catalogs/src/api/application/tax-regimes/public/models/tax-regime-public.model.ts b/modules/catalogs/src/api/application/tax-regimes/public/models/tax-regime-public.model.ts new file mode 100644 index 00000000..7cec6da6 --- /dev/null +++ b/modules/catalogs/src/api/application/tax-regimes/public/models/tax-regime-public.model.ts @@ -0,0 +1,20 @@ +import type { UniqueID } from "@repo/rdx-ddd"; + +/** + * Modelo público de lectura expuesto por el módulo `catalogs`. + * + * Es un contrato backend entre módulos, no un DTO HTTP y no una entidad + * de dominio. Puede usar Value Objects comunes del ERP, pero no debe + * exponer Value Objects internos de `tax-regimes`. + */ +export interface TaxRegimePublicModel { + id: UniqueID; + companyId: UniqueID; + + code: string; + description: string; + + isSystem: boolean; + isActive: boolean; + +} diff --git a/modules/catalogs/src/api/application/tax-regimes/public/services/index.ts b/modules/catalogs/src/api/application/tax-regimes/public/services/index.ts new file mode 100644 index 00000000..e5063181 --- /dev/null +++ b/modules/catalogs/src/api/application/tax-regimes/public/services/index.ts @@ -0,0 +1,2 @@ +export * from './tax-regime-public-finder.interface'; +export * from './tax-regime-public-finder'; diff --git a/modules/catalogs/src/api/application/tax-regimes/public/services/tax-regime-public-finder.interface.ts b/modules/catalogs/src/api/application/tax-regimes/public/services/tax-regime-public-finder.interface.ts new file mode 100644 index 00000000..413ad6ac --- /dev/null +++ b/modules/catalogs/src/api/application/tax-regimes/public/services/tax-regime-public-finder.interface.ts @@ -0,0 +1,24 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Maybe, Result } from "@repo/rdx-utils"; + +import type { TaxRegimePublicModel } from "../models"; + +export interface FindTaxRegimeByCodeInCompanyParams { + companyId: UniqueID; + code: string; + transaction?: unknown; +} + +export interface ITaxRegimePublicFinder { + existsByCodeInCompany( + params: FindTaxRegimeByCodeInCompanyParams, + ): Promise>; + + getByCodeInCompany( + params: FindTaxRegimeByCodeInCompanyParams, + ): Promise>; + + findByCodeInCompany( + params: FindTaxRegimeByCodeInCompanyParams, + ): Promise, Error>>; +} \ No newline at end of file diff --git a/modules/catalogs/src/api/application/tax-regimes/public/services/tax-regime-public-finder.ts b/modules/catalogs/src/api/application/tax-regimes/public/services/tax-regime-public-finder.ts new file mode 100644 index 00000000..52ab6cb1 --- /dev/null +++ b/modules/catalogs/src/api/application/tax-regimes/public/services/tax-regime-public-finder.ts @@ -0,0 +1,96 @@ +import { Maybe, Result } from "@repo/rdx-utils"; + +import { TaxRegimeCode } from "../../../../domain/tax-regimes"; +import type { ITaxRegimeRepository } from "../../repositories"; +import type { TaxRegimePublicModelMapper } from "../mappers/tax-regime-public-model.mapper"; +import type { TaxRegimePublicModel } from "../models"; +import type { + FindTaxRegimeByCodeInCompanyParams, + ITaxRegimePublicFinder, +} from "./tax-regime-public-finder.interface"; + +export class TaxRegimePublicFinder implements ITaxRegimePublicFinder { + public constructor( + private readonly deps: { + repository: ITaxRegimeRepository; + mapper: TaxRegimePublicModelMapper; + }, + ) {} + + public async existsByCodeInCompany( + params: FindTaxRegimeByCodeInCompanyParams, + ): Promise> { + const code = TaxRegimeCode.create(params.code); + + if (code.isFailure) { + return Result.fail(code.error); + } + + const result = await this.deps.repository.existsByCodeInCompany( + params.companyId, + code.data, + params.transaction, + ); + + if (result.isFailure) { + return Result.fail(result.error); + } + + return Result.ok(result.data); + } + + public async getByCodeInCompany( + params: FindTaxRegimeByCodeInCompanyParams, + ): Promise> { + const code = TaxRegimeCode.create(params.code); + + if (code.isFailure) { + return Result.fail(code.error); + } + + const result = await this.deps.repository.getByCodeInCompany( + params.companyId, + code.data, + params.transaction, + ); + + if (result.isFailure) { + return Result.fail(result.error); + } + + return Result.ok(this.deps.mapper.toPublicModel(result.data)); + } + + /** + * Busca un régimen fiscal por código dentro de una empresa. + * + * No falla si el régimen fiscal no existe. En ese caso devuelve `Maybe.none()`. + * Usar `getByCodeInCompany` cuando la ausencia deba tratarse como error. + */ + public async findByCodeInCompany( + params: FindTaxRegimeByCodeInCompanyParams, + ): Promise, Error>> { + const code = TaxRegimeCode.create(params.code); + + if (code.isFailure) { + return Result.fail(code.error); + } + + const result = await this.deps.repository.findByCodeInCompany( + params.companyId, + code.data, + params.transaction, + ); + + if (result.isFailure) { + return Result.fail(result.error); + } + + return Result.ok( + result.data.match( + (taxRegime) => Maybe.some(this.deps.mapper.toPublicModel(taxRegime)), + () => Maybe.none(), + ), + ); + } +} \ No newline at end of file diff --git a/modules/catalogs/src/api/application/tax-regimes/repositories/tax-regime-repository.interface.ts b/modules/catalogs/src/api/application/tax-regimes/repositories/tax-regime-repository.interface.ts index 21253aec..0eaf3509 100644 --- a/modules/catalogs/src/api/application/tax-regimes/repositories/tax-regime-repository.interface.ts +++ b/modules/catalogs/src/api/application/tax-regimes/repositories/tax-regime-repository.interface.ts @@ -1,6 +1,6 @@ import type { Criteria } from "@repo/rdx-criteria/server"; import type { UniqueID } from "@repo/rdx-ddd"; -import type { Collection, Result } from "@repo/rdx-utils"; +import type { Collection, Maybe, Result } from "@repo/rdx-utils"; import type { TaxRegime, TaxRegimeCode } from "../../../domain"; import type { TaxRegimeSummary } from "../models"; @@ -20,6 +20,12 @@ export interface ITaxRegimeRepository { transaction?: unknown ): Promise>; + existsByCodeInCompany( + companyId: UniqueID, + code: TaxRegimeCode, + transaction?: unknown + ): Promise>; + getByIdInCompany( companyId: UniqueID, id: UniqueID, @@ -32,6 +38,12 @@ export interface ITaxRegimeRepository { transaction?: unknown ): Promise>; + findByCodeInCompany( + companyId: UniqueID, + code: TaxRegimeCode, + transaction?: unknown + ): Promise, Error>>; + findByCriteriaInCompany( companyId: UniqueID, criteria: Criteria, diff --git a/modules/catalogs/src/api/domain/tax-definitions/errors.ts b/modules/catalogs/src/api/domain/tax-definitions/errors.ts index 903b427c..731ad620 100644 --- a/modules/catalogs/src/api/domain/tax-definitions/errors.ts +++ b/modules/catalogs/src/api/domain/tax-definitions/errors.ts @@ -61,24 +61,6 @@ export const isInvalidTaxDefinitionCalculationBehaviorError = ( ): e is InvalidTaxDefinitionCalculationBehaviorError => e instanceof InvalidTaxDefinitionCalculationBehaviorError; -export class InvalidTaxDefinitionJurisdictionCountryCodeError extends DomainError { - public readonly code = "TAX_DEFINITION_INVALID_JURISDICTION_COUNTRY_CODE" as const; -} - -export const isInvalidTaxDefinitionJurisdictionCountryCodeError = ( - e: unknown -): e is InvalidTaxDefinitionJurisdictionCountryCodeError => - e instanceof InvalidTaxDefinitionJurisdictionCountryCodeError; - -export class InvalidTaxDefinitionJurisdictionRegionCodeError extends DomainError { - public readonly code = "TAX_DEFINITION_INVALID_JURISDICTION_REGION_CODE" as const; -} - -export const isInvalidTaxDefinitionJurisdictionRegionCodeError = ( - e: unknown -): e is InvalidTaxDefinitionJurisdictionRegionCodeError => - e instanceof InvalidTaxDefinitionJurisdictionRegionCodeError; - export class InvalidTaxDefinitionScopeError extends DomainError { public readonly code = "TAX_DEFINITION_INVALID_SCOPE" as const; } diff --git a/modules/catalogs/src/api/domain/tax-definitions/index.ts b/modules/catalogs/src/api/domain/tax-definitions/index.ts index 47ec912b..dfd50c16 100644 --- a/modules/catalogs/src/api/domain/tax-definitions/index.ts +++ b/modules/catalogs/src/api/domain/tax-definitions/index.ts @@ -1,7 +1,5 @@ export * from "./calculation-behavior"; export * from "./errors"; -export * from "./jurisdiction-country-code"; -export * from "./jurisdiction-region-code"; export * from "./tax-definition.aggregate"; export * from "./tax-definition-code"; export * from "./tax-definition-name"; diff --git a/modules/catalogs/src/api/domain/tax-definitions/jurisdiction-country-code.ts b/modules/catalogs/src/api/domain/tax-definitions/jurisdiction-country-code.ts deleted file mode 100644 index a3fbda89..00000000 --- a/modules/catalogs/src/api/domain/tax-definitions/jurisdiction-country-code.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Result } from "@repo/rdx-utils"; - -import { InvalidTaxDefinitionJurisdictionCountryCodeError } from "./errors"; - -export class TaxJurisdictionCountryCode { - private constructor(private readonly value: string) {} - - public static create(code: string): Result { - const trimmed = code?.trim() ?? ""; - if (trimmed.length !== 2) { - return Result.fail( - new InvalidTaxDefinitionJurisdictionCountryCodeError("Country code must be 2 letters") - ); - } - - const normalized = trimmed.toUpperCase(); - const regex = /^[A-Z]{2}$/; - if (!regex.test(normalized)) { - return Result.fail( - new InvalidTaxDefinitionJurisdictionCountryCodeError( - "Country code must be 2 uppercase letters" - ) - ); - } - - // NOTE: We allow two-letter codes including 'EU' to support union-level definitions like reverse charge. - return Result.ok(new TaxJurisdictionCountryCode(normalized)); - } - - public static fromPersistence(code: string): TaxJurisdictionCountryCode { - return new TaxJurisdictionCountryCode(code); - } - - public toPrimitive(): string { - return this.value; - } - - public toString(): string { - return this.value; - } -} diff --git a/modules/catalogs/src/api/domain/tax-definitions/jurisdiction-region-code.ts b/modules/catalogs/src/api/domain/tax-definitions/jurisdiction-region-code.ts deleted file mode 100644 index 58a60136..00000000 --- a/modules/catalogs/src/api/domain/tax-definitions/jurisdiction-region-code.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Result } from "@repo/rdx-utils"; - -import { InvalidTaxDefinitionJurisdictionRegionCodeError } from "./errors"; - -export class TaxJurisdictionRegionCode { - private constructor(private readonly value: string) {} - - public static create(code: string): Result { - const trimmed = code?.trim() ?? ""; - if (trimmed.length === 0) { - return Result.fail( - new InvalidTaxDefinitionJurisdictionRegionCodeError("Region code cannot be empty") - ); - } - - const normalized = trimmed.toUpperCase(); - - // basic pattern: CC-... (we won't validate full ISO-3166-2 list) - const regex = /^[A-Z]{2}-[A-Z0-9-]+$/; - if (!regex.test(normalized)) { - return Result.fail( - new InvalidTaxDefinitionJurisdictionRegionCodeError( - "Region code must follow ISO-3166-2 pattern COUNTRY-REGION" - ) - ); - } - - return Result.ok(new TaxJurisdictionRegionCode(normalized)); - } - - public static fromPersistence(code: string): TaxJurisdictionRegionCode { - return new TaxJurisdictionRegionCode(code); - } - - public toPrimitive(): string { - return this.value; - } - - public toString(): string { - return this.value; - } -} diff --git a/modules/catalogs/src/api/domain/tax-definitions/tax-definition.aggregate.ts b/modules/catalogs/src/api/domain/tax-definitions/tax-definition.aggregate.ts index ab97167c..fda4c524 100644 --- a/modules/catalogs/src/api/domain/tax-definitions/tax-definition.aggregate.ts +++ b/modules/catalogs/src/api/domain/tax-definitions/tax-definition.aggregate.ts @@ -1,14 +1,18 @@ -import { AggregateRoot, type TextValue, type UniqueID, type UtcDate } from "@repo/rdx-ddd"; +import { + AggregateRoot, + type CountryCode, + type CountryRegionCode, + type TextValue, + type UniqueID, + type UtcDate, +} from "@repo/rdx-ddd"; import { type Maybe, Result } from "@repo/rdx-utils"; import type { TaxCalculationBehavior } from "./calculation-behavior"; import { InvalidTaxDefinitionAllowedSurchargeCodesError, - InvalidTaxDefinitionJurisdictionRegionCodeError, InvalidTaxDefinitionValidityPeriodError, } from "./errors"; -import type { TaxJurisdictionCountryCode } from "./jurisdiction-country-code"; -import type { TaxJurisdictionRegionCode } from "./jurisdiction-region-code"; import type { TaxDefinitionCode } from "./tax-definition-code"; import type { TaxDefinitionName } from "./tax-definition-name"; import type { TaxFamily } from "./tax-family"; @@ -23,8 +27,8 @@ export interface ITaxDefinitionCreateProps { rate: TaxRate; taxFamily: TaxFamily; calculationBehavior: TaxCalculationBehavior; - jurisdictionCountryCode: TaxJurisdictionCountryCode; - jurisdictionRegionCode: Maybe; + jurisdictionCountryCode: CountryCode; + jurisdictionRegionCode: Maybe; taxScope: TaxScope; invoiceNote: Maybe; // Texto fiscal que aparece en el documento allowedSurchargeCodes: Maybe; @@ -146,19 +150,6 @@ export class TaxDefinition extends AggregateRoot { } } - // region coherence with country - if (props.jurisdictionRegionCode && props.jurisdictionRegionCode.isSome()) { - const region = props.jurisdictionRegionCode.unwrap().toPrimitive(); - const country = props.jurisdictionCountryCode.toPrimitive(); - if (!region.startsWith(`${country}-`)) { - return Result.fail( - new InvalidTaxDefinitionJurisdictionRegionCodeError( - "Region code must start with country code" - ) - ); - } - } - return Result.ok(); } @@ -198,11 +189,11 @@ export class TaxDefinition extends AggregateRoot { return this.props.calculationBehavior; } - public get jurisdictionCountryCode(): TaxJurisdictionCountryCode { + public get jurisdictionCountryCode(): CountryCode { return this.props.jurisdictionCountryCode; } - public get jurisdictionRegionCode(): Maybe { + public get jurisdictionRegionCode(): Maybe { return this.props.jurisdictionRegionCode; } diff --git a/modules/catalogs/src/api/index.ts b/modules/catalogs/src/api/index.ts index 72d49956..ad825e3c 100644 --- a/modules/catalogs/src/api/index.ts +++ b/modules/catalogs/src/api/index.ts @@ -5,6 +5,8 @@ import { paymentMethodsRouter, paymentTermModels, paymentTermsRouter, + taxDefinitionModels, + taxDefinitionsRouter, taxRegimeModels, taxRegimesRouter, } from "./infrastructure"; @@ -13,10 +15,14 @@ import { buildCatalogsPublicServices, } from "./infrastructure/di/catalogs.di"; -export * from "./application/services/catalog-public-services.interface"; // <- exportamos la interfaz de los servicios públicos para que otros módulos puedan usarla en sus dependencias +export * from "./application/payment-methods/public"; +export * from "./application/tax-definitions/public"; +export * from "./application/tax-regimes/public"; //export * from "./infrastructure/payment-methods/persistence/sequelize"; <- ??? +export type CatalogsPublicServicesType = ReturnType; + export const catalogsAPIModule: IModuleServer = { name: "catalogs", version: "1.0.0", @@ -44,13 +50,16 @@ export const catalogsAPIModule: IModuleServer = { return { // Modelos Sequelize del módulo - models: [...paymentMethodModels, ...paymentTermModels, ...taxRegimeModels], + models: [ + ...paymentMethodModels, + ...paymentTermModels, + ...taxDefinitionModels, + ...taxRegimeModels, + ], // Servicios expuestos a otros módulos services: { - paymentMethods: publicServices.paymentMethods, - paymentTerms: publicServices.paymentTerms, - taxRegimes: publicServices.taxRegimes, + ...publicServices, }, // Implementación privada del módulo @@ -72,6 +81,7 @@ export const catalogsAPIModule: IModuleServer = { paymentMethodsRouter(params); paymentTermsRouter(params); taxRegimesRouter(params); + taxDefinitionsRouter(params); logger.info("🚀 Catalogs module started", { label: this.name, diff --git a/modules/catalogs/src/api/infrastructure/di/catalogs.di.ts b/modules/catalogs/src/api/infrastructure/di/catalogs.di.ts index ebf601e4..ed8cc9ab 100644 --- a/modules/catalogs/src/api/infrastructure/di/catalogs.di.ts +++ b/modules/catalogs/src/api/infrastructure/di/catalogs.di.ts @@ -10,6 +10,11 @@ import { buildPaymentTermsDependencies, buildPaymentTermsPublicServices, } from "../payment-terms/di"; +import { + type TaxDefinitionsInternalDeps, + buildTaxDefinitionsDependencies, + buildTaxDefinitionsPublicServices, +} from "../tax-definitions/di"; import { type TaxRegimesInternalDeps, buildTaxRegimesDependencies, @@ -19,6 +24,7 @@ import { export type CatalogsInternalDeps = { paymentMethods: PaymentMethodsInternalDeps; paymentTerms: PaymentTermsInternalDeps; + taxDefinitions: TaxDefinitionsInternalDeps; taxRegimes: TaxRegimesInternalDeps; }; @@ -26,6 +32,7 @@ export const buildCatalogsDependencies = (params: ModuleParams): CatalogsInterna return { paymentMethods: buildPaymentMethodsDependencies(params), paymentTerms: buildPaymentTermsDependencies(params), + taxDefinitions: buildTaxDefinitionsDependencies(params), taxRegimes: buildTaxRegimesDependencies(params), }; }; @@ -34,6 +41,7 @@ export const buildCatalogsPublicServices = (params: SetupParams, deps: CatalogsI return { paymentMethods: buildPaymentMethodsPublicServices(params, deps.paymentMethods), paymentTerms: buildPaymentTermsPublicServices(params, deps.paymentTerms), + taxDefinitions: buildTaxDefinitionsPublicServices(params, deps.taxDefinitions), taxRegimes: buildTaxRegimesPublicServices(params, deps.taxRegimes), }; }; diff --git a/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/repositories/sequelize-payment-method.repository.ts b/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/repositories/sequelize-payment-method.repository.ts index 5b5a1576..97338523 100644 --- a/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/repositories/sequelize-payment-method.repository.ts +++ b/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/repositories/sequelize-payment-method.repository.ts @@ -6,7 +6,7 @@ import { } from "@erp/core/api"; import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server"; import type { UniqueID } from "@repo/rdx-ddd"; -import { type Collection, Result } from "@repo/rdx-utils"; +import { type Collection, Maybe, Result } from "@repo/rdx-utils"; import type { Sequelize, Transaction } from "sequelize"; import type { IPaymentMethodRepository } from "../../../../../application"; @@ -145,6 +145,35 @@ export class SequelizePaymentMethodRepository } } + async findByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction?: Transaction + ): Promise, Error>> { + try { + const row = await PaymentMethodModel.findOne({ + where: { + id: id.toString(), + company_id: companyId.toString(), + }, + transaction, + }); + + if (!row) { + return Result.ok(Maybe.none()); + } + + const mappedResult = this.domainMapper.mapToDomain(row); + if (mappedResult.isFailure) { + return Result.fail(mappedResult.error); + } + + return Result.ok(Maybe.some(mappedResult.data)); + } catch (err: unknown) { + return Result.fail(translateSequelizeError(err)); + } + } + /** * Recupera múltiples customers dentro de una empresa según un criterio dinámico (búsqueda, paginación, etc.). * diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/di/index.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/di/index.ts index 6b086d9c..f7fb465a 100644 --- a/modules/catalogs/src/api/infrastructure/tax-definitions/di/index.ts +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/di/index.ts @@ -1,2 +1,3 @@ export * from "./tax-definition-persistence-mappers.di"; export * from "./tax-definition-repositories.di"; +export * from "./tax-definitions.di"; diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/di/tax-definitions.di.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/di/tax-definitions.di.ts new file mode 100644 index 00000000..18040f4a --- /dev/null +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/di/tax-definitions.di.ts @@ -0,0 +1,125 @@ +import { type ModuleParams, type SetupParams, buildTransactionManager } from "@erp/core/api"; +import type { Sequelize } from "sequelize"; + +import { + CreateTaxDefinitionInputMapper, + CreateTaxDefinitionUseCase, + DeleteTaxDefinitionByIdUseCase, + DisableTaxDefinitionByIdUseCase, + EnableTaxDefinitionByIdUseCase, + GetTaxDefinitionByIdUseCase, + type ITaxDefinitionPublicFinder, + type ITaxDefinitionRepository, + ListTaxDefinitionsUseCase, + TaxDefinitionCreator, + TaxDefinitionDeleter, + TaxDefinitionFinder, + TaxDefinitionFullSnapshotBuilder, + TaxDefinitionPublicFinder, + TaxDefinitionPublicModelMapper, + TaxDefinitionStatusChanger, + TaxDefinitionSummarySnapshotBuilder, + TaxDefinitionUpdater, + UpdateTaxDefinitionByIdInputMapper, + UpdateTaxDefinitionByIdUseCase, +} from "../../../application"; + +import { buildTaxDefinitionPersistenceMappers } from "./tax-definition-persistence-mappers.di"; +import { buildTaxDefinitionRepository } from "./tax-definition-repositories.di"; + +export type TaxDefinitionsInternalDeps = { + repository: ITaxDefinitionRepository; + useCases: { + listTaxDefinitions: () => ListTaxDefinitionsUseCase; + getTaxDefinitionById: () => GetTaxDefinitionByIdUseCase; + createTaxDefinition: () => CreateTaxDefinitionUseCase; + updateTaxDefinitionById: () => UpdateTaxDefinitionByIdUseCase; + deleteTaxDefinitionById: () => DeleteTaxDefinitionByIdUseCase; + disableTaxDefinitionById: () => DisableTaxDefinitionByIdUseCase; + enableTaxDefinitionById: () => EnableTaxDefinitionByIdUseCase; + }; +}; + +export const buildTaxDefinitionsDependencies = ( + params: ModuleParams +): TaxDefinitionsInternalDeps => { + const { database } = params; + + const transactionManager = buildTransactionManager(database as Sequelize); + const persistenceMappers = buildTaxDefinitionPersistenceMappers(); + + const repository = buildTaxDefinitionRepository({ database, mappers: persistenceMappers }); + + const finder = new TaxDefinitionFinder(repository); + const creator = new TaxDefinitionCreator(repository); + const updater = new TaxDefinitionUpdater(repository); + const deleter = new TaxDefinitionDeleter(repository); + const statusChanger = new TaxDefinitionStatusChanger(repository); + + const createInputMapper = new CreateTaxDefinitionInputMapper(); + const updateInputMapper = new UpdateTaxDefinitionByIdInputMapper(); + + const fullSnapshotBuilder = new TaxDefinitionFullSnapshotBuilder(); + const summarySnapshotBuilder = new TaxDefinitionSummarySnapshotBuilder(); + + return { + repository, + useCases: { + listTaxDefinitions: () => + new ListTaxDefinitionsUseCase({ + repository, + summarySnapshotBuilder, + }), + + getTaxDefinitionById: () => + new GetTaxDefinitionByIdUseCase({ + finder, + fullSnapshotBuilder, + }), + + createTaxDefinition: () => + new CreateTaxDefinitionUseCase({ + dtoMapper: createInputMapper, + creator, + fullSnapshotBuilder, + transactionManager, + }), + + updateTaxDefinitionById: () => + new UpdateTaxDefinitionByIdUseCase({ + dtoMapper: updateInputMapper, + updater, + fullSnapshotBuilder, + }), + + deleteTaxDefinitionById: () => + new DeleteTaxDefinitionByIdUseCase({ + deleter, + }), + + disableTaxDefinitionById: () => + new DisableTaxDefinitionByIdUseCase({ + statusChanger, + }), + + enableTaxDefinitionById: () => + new EnableTaxDefinitionByIdUseCase({ + statusChanger, + }), + }, + }; +}; + +export const buildTaxDefinitionsPublicServices = ( + _params: SetupParams, + deps: TaxDefinitionsInternalDeps +): { finder: ITaxDefinitionPublicFinder } => { + const mapper = new TaxDefinitionPublicModelMapper(); + + return { + finder: new TaxDefinitionPublicFinder({ + repository: deps.repository, + mapper, + }), + }; +}; diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/express/controllers/create-tax-definition.controller.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/express/controllers/create-tax-definition.controller.ts new file mode 100644 index 00000000..90db7576 --- /dev/null +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/express/controllers/create-tax-definition.controller.ts @@ -0,0 +1,39 @@ +import type { CreateTaxDefinitionRequestDTO } from "@erp/catalogs/common"; +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; + +import type { CreateTaxDefinitionUseCase } from "../../../../application/tax-definitions"; +import { taxDefinitionsApiErrorMapper } from "../tax-definitions-api-error-mapper"; + +export class CreateTaxDefinitionController extends ExpressController { + constructor(private readonly useCase: CreateTaxDefinitionUseCase) { + super(); + + this.errorMapper = taxDefinitionsApiErrorMapper; + + this.registerGuards( + requireAuthenticatedGuard(), + requireCompanyContextGuard(), + forbidQueryFieldGuard("companyId") + ); + } + + protected async executeImpl() { + const companyId = this.getTenantId(); + if (!companyId) { + return this.forbiddenError("Tenant ID not found"); + } + + const dto = this.req.body satisfies CreateTaxDefinitionRequestDTO; + const result = await this.useCase.execute({ dto, companyId }); + + return result.match( + (data: unknown) => this.created(data), + (err: Error) => this.handleError(err) + ); + } +} diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/express/controllers/delete-tax-definition-by-id.controller.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/express/controllers/delete-tax-definition-by-id.controller.ts new file mode 100644 index 00000000..8d436c58 --- /dev/null +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/express/controllers/delete-tax-definition-by-id.controller.ts @@ -0,0 +1,38 @@ +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; + +import type { DeleteTaxDefinitionByIdUseCase } from "../../../../application/tax-definitions"; +import { taxDefinitionsApiErrorMapper } from "../tax-definitions-api-error-mapper"; + +export class DeleteTaxDefinitionByIdController extends ExpressController { + constructor(private readonly useCase: DeleteTaxDefinitionByIdUseCase) { + super(); + + this.errorMapper = taxDefinitionsApiErrorMapper; + + this.registerGuards( + requireAuthenticatedGuard(), + requireCompanyContextGuard(), + forbidQueryFieldGuard("companyId") + ); + } + + protected async executeImpl() { + const companyId = this.getTenantId(); + if (!companyId) { + return this.forbiddenError("Tenant ID not found"); + } + + const { tax_definition_id } = this.req.params; + const result = await this.useCase.execute(companyId, tax_definition_id as any); + + return result.match( + (data: unknown) => this.ok(data), + (err: Error) => this.handleError(err) + ); + } +} diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/express/controllers/disable-tax-definition-by-id.controller.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/express/controllers/disable-tax-definition-by-id.controller.ts new file mode 100644 index 00000000..24df8959 --- /dev/null +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/express/controllers/disable-tax-definition-by-id.controller.ts @@ -0,0 +1,38 @@ +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; + +import type { DisableTaxDefinitionByIdUseCase } from "../../../../application/tax-definitions"; +import { taxDefinitionsApiErrorMapper } from "../tax-definitions-api-error-mapper"; + +export class DisableTaxDefinitionByIdController extends ExpressController { + constructor(private readonly useCase: DisableTaxDefinitionByIdUseCase) { + super(); + + this.errorMapper = taxDefinitionsApiErrorMapper; + + this.registerGuards( + requireAuthenticatedGuard(), + requireCompanyContextGuard(), + forbidQueryFieldGuard("companyId") + ); + } + + protected async executeImpl() { + const companyId = this.getTenantId(); + if (!companyId) { + return this.forbiddenError("Tenant ID not found"); + } + + const { tax_definition_id } = this.req.params; + const result = await this.useCase.execute(companyId, tax_definition_id as any); + + return result.match( + (data: unknown) => this.ok(data), + (err: Error) => this.handleError(err) + ); + } +} diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/express/controllers/enable-tax-definition-by-id.controller.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/express/controllers/enable-tax-definition-by-id.controller.ts new file mode 100644 index 00000000..3dc47a27 --- /dev/null +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/express/controllers/enable-tax-definition-by-id.controller.ts @@ -0,0 +1,38 @@ +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; + +import type { EnableTaxDefinitionByIdUseCase } from "../../../../application/tax-definitions"; +import { taxDefinitionsApiErrorMapper } from "../tax-definitions-api-error-mapper"; + +export class EnableTaxDefinitionByIdController extends ExpressController { + constructor(private readonly useCase: EnableTaxDefinitionByIdUseCase) { + super(); + + this.errorMapper = taxDefinitionsApiErrorMapper; + + this.registerGuards( + requireAuthenticatedGuard(), + requireCompanyContextGuard(), + forbidQueryFieldGuard("companyId") + ); + } + + protected async executeImpl() { + const companyId = this.getTenantId(); + if (!companyId) { + return this.forbiddenError("Tenant ID not found"); + } + + const { tax_definition_id } = this.req.params; + const result = await this.useCase.execute(companyId, tax_definition_id as any); + + return result.match( + (data: unknown) => this.ok(data), + (err: Error) => this.handleError(err) + ); + } +} diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/express/controllers/get-tax-definition-by-id.controller.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/express/controllers/get-tax-definition-by-id.controller.ts new file mode 100644 index 00000000..44070907 --- /dev/null +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/express/controllers/get-tax-definition-by-id.controller.ts @@ -0,0 +1,38 @@ +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; + +import type { GetTaxDefinitionByIdUseCase } from "../../../../application/tax-definitions"; +import { taxDefinitionsApiErrorMapper } from "../tax-definitions-api-error-mapper"; + +export class GetTaxDefinitionByIdController extends ExpressController { + constructor(private readonly useCase: GetTaxDefinitionByIdUseCase) { + super(); + + this.errorMapper = taxDefinitionsApiErrorMapper; + + this.registerGuards( + requireAuthenticatedGuard(), + requireCompanyContextGuard(), + forbidQueryFieldGuard("companyId") + ); + } + + protected async executeImpl() { + const companyId = this.getTenantId(); + if (!companyId) { + return this.forbiddenError("Tenant ID not found"); + } + + const { tax_definition_id } = this.req.params; + const result = await this.useCase.execute(companyId, tax_definition_id as any); + + return result.match( + (data) => this.ok(data), + (err: Error) => this.handleError(err) + ); + } +} diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/express/controllers/index.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/express/controllers/index.ts new file mode 100644 index 00000000..4543a68f --- /dev/null +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/express/controllers/index.ts @@ -0,0 +1,7 @@ +export * from "./create-tax-definition.controller"; +export * from "./delete-tax-definition-by-id.controller"; +export * from "./disable-tax-definition-by-id.controller"; +export * from "./enable-tax-definition-by-id.controller"; +export * from "./get-tax-definition-by-id.controller"; +export * from "./list-tax-definitions.controller"; +export * from "./update-tax-definition-by-id.controller"; diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/express/controllers/list-tax-definitions.controller.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/express/controllers/list-tax-definitions.controller.ts new file mode 100644 index 00000000..98049570 --- /dev/null +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/express/controllers/list-tax-definitions.controller.ts @@ -0,0 +1,54 @@ +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; +import { Criteria } from "@repo/rdx-criteria/server"; + +import type { ListTaxDefinitionsUseCase } from "../../../../application/tax-definitions"; +import { taxDefinitionsApiErrorMapper } from "../tax-definitions-api-error-mapper"; + +export class ListTaxDefinitionsController extends ExpressController { + constructor(private readonly useCase: ListTaxDefinitionsUseCase) { + super(); + + this.errorMapper = taxDefinitionsApiErrorMapper; + + this.registerGuards( + requireAuthenticatedGuard(), + requireCompanyContextGuard(), + forbidQueryFieldGuard("companyId") + ); + } + + private getCriteriaWithDefaultOrder() { + if (this.criteria.hasOrder()) { + return this.criteria; + } + + const { q: quicksearch, filters, pageSize, pageNumber } = this.criteria.toPrimitives(); + return Criteria.fromPrimitives(filters, "code", "ASC", pageSize, pageNumber, quicksearch); + } + + protected async executeImpl() { + const companyId = this.getTenantId(); + if (!companyId) { + return this.forbiddenError("Tenant ID not found"); + } + + const criteria = this.getCriteriaWithDefaultOrder(); + const result = await this.useCase.execute(companyId, criteria); + + return result.match( + (data: any) => + this.ok(data, { + "X-Total-Count": String(data.total_items), + "Pagination-Count": String(data.total_pages), + "Pagination-Page": String(data.page), + "Pagination-Limit": String(data.per_page), + }), + (err: Error) => this.handleError(err) + ); + } +} diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/express/controllers/update-tax-definition-by-id.controller.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/express/controllers/update-tax-definition-by-id.controller.ts new file mode 100644 index 00000000..c2aba57c --- /dev/null +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/express/controllers/update-tax-definition-by-id.controller.ts @@ -0,0 +1,44 @@ +import type { UpdateTaxDefinitionByIdRequestDTO } from "@erp/catalogs/common"; +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; + +import type { UpdateTaxDefinitionByIdUseCase } from "../../../../application/tax-definitions"; +import { taxDefinitionsApiErrorMapper } from "../tax-definitions-api-error-mapper"; + +export class UpdateTaxDefinitionByIdController extends ExpressController { + constructor(private readonly useCase: UpdateTaxDefinitionByIdUseCase) { + super(); + + this.errorMapper = taxDefinitionsApiErrorMapper; + + this.registerGuards( + requireAuthenticatedGuard(), + requireCompanyContextGuard(), + forbidQueryFieldGuard("companyId") + ); + } + + protected async executeImpl() { + const companyId = this.getTenantId(); + if (!companyId) { + return this.forbiddenError("Tenant ID not found"); + } + + const { tax_definition_id } = this.req.params; + if (!tax_definition_id) { + return this.invalidInputError("Tax definition ID missing"); + } + + const dto = this.req.body as UpdateTaxDefinitionByIdRequestDTO; + const result = await this.useCase.execute(companyId, { id: tax_definition_id as any, dto }); + + return result.match( + (data: unknown) => this.ok(data), + (err: Error) => this.handleError(err) + ); + } +} diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/express/index.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/express/index.ts new file mode 100644 index 00000000..9a210359 --- /dev/null +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/express/index.ts @@ -0,0 +1,2 @@ +export * from "./controllers"; +export * from "./tax-definitions.routes"; diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/express/tax-definitions-api-error-mapper.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/express/tax-definitions-api-error-mapper.ts new file mode 100644 index 00000000..704be02e --- /dev/null +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/express/tax-definitions-api-error-mapper.ts @@ -0,0 +1,186 @@ +import { + ApiErrorMapper, + ConflictApiError, + EntityNotFoundError, + type ErrorToApiRule, + NotFoundApiError, + ValidationApiError, +} from "@erp/core/api"; + +import { + type InvalidTaxDefinitionAllowedSurchargeCodesError, + type InvalidTaxDefinitionCalculationBehaviorError, + type InvalidTaxDefinitionCodeError, + type InvalidTaxDefinitionDescriptionError, + type InvalidTaxDefinitionFamilyError, + type InvalidTaxDefinitionIdError, + type InvalidTaxDefinitionInvoiceNoteError, + type InvalidTaxDefinitionNameError, + type InvalidTaxDefinitionRateError, + type InvalidTaxDefinitionScopeError, + type InvalidTaxDefinitionValidityPeriodError, + type TaxDefinitionCannotBeDisabledError, + type TaxDefinitionCannotBeEnabledError, + isInvalidTaxDefinitionAllowedSurchargeCodesError, + isInvalidTaxDefinitionCalculationBehaviorError, + isInvalidTaxDefinitionCodeError, + isInvalidTaxDefinitionDescriptionError, + isInvalidTaxDefinitionFamilyError, + isInvalidTaxDefinitionIdError, + isInvalidTaxDefinitionInvoiceNoteError, + isInvalidTaxDefinitionNameError, + isInvalidTaxDefinitionRateError, + isInvalidTaxDefinitionScopeError, + isInvalidTaxDefinitionValidityPeriodError, + isTaxDefinitionCannotBeDisabledError, + isTaxDefinitionCannotBeEnabledError, +} from "../../../domain/tax-definitions"; + +const invalidTaxDefinitionIdRule: ErrorToApiRule = { + priority: 120, + matches: isInvalidTaxDefinitionIdError, + build: (error) => + new ConflictApiError( + (error as InvalidTaxDefinitionIdError).message || + "Tax definition with the provided id already exists." + ), +}; + +const invalidTaxDefinitionCodeRule: ErrorToApiRule = { + priority: 120, + matches: isInvalidTaxDefinitionCodeError, + build: (error) => + new ValidationApiError( + (error as InvalidTaxDefinitionCodeError).message || "Tax definition code is invalid." + ), +}; + +const invalidTaxDefinitionNameRule: ErrorToApiRule = { + priority: 120, + matches: isInvalidTaxDefinitionNameError, + build: (error) => + new ValidationApiError( + (error as InvalidTaxDefinitionNameError).message || "Tax definition name is invalid." + ), +}; + +const invalidTaxDefinitionDescriptionRule: ErrorToApiRule = { + priority: 120, + matches: isInvalidTaxDefinitionDescriptionError, + build: (error) => + new ValidationApiError( + (error as InvalidTaxDefinitionDescriptionError).message || + "Tax definition description is invalid." + ), +}; + +const invalidTaxDefinitionRateRule: ErrorToApiRule = { + priority: 120, + matches: isInvalidTaxDefinitionRateError, + build: (error) => + new ValidationApiError( + (error as InvalidTaxDefinitionRateError).message || "Tax definition rate is invalid." + ), +}; + +const invalidTaxDefinitionFamilyRule: ErrorToApiRule = { + priority: 120, + matches: isInvalidTaxDefinitionFamilyError, + build: (error) => + new ValidationApiError( + (error as InvalidTaxDefinitionFamilyError).message || "Tax definition family is invalid." + ), +}; + +const invalidTaxDefinitionCalculationBehaviorRule: ErrorToApiRule = { + priority: 120, + matches: isInvalidTaxDefinitionCalculationBehaviorError, + build: (error) => + new ValidationApiError( + (error as InvalidTaxDefinitionCalculationBehaviorError).message || + "Tax definition calculation behavior is invalid." + ), +}; + +const invalidTaxDefinitionScopeRule: ErrorToApiRule = { + priority: 120, + matches: isInvalidTaxDefinitionScopeError, + build: (error) => + new ValidationApiError( + (error as InvalidTaxDefinitionScopeError).message || "Tax definition scope is invalid." + ), +}; + +const invalidTaxDefinitionInvoiceNoteRule: ErrorToApiRule = { + priority: 120, + matches: isInvalidTaxDefinitionInvoiceNoteError, + build: (error) => + new ValidationApiError( + (error as InvalidTaxDefinitionInvoiceNoteError).message || + "Tax definition invoice note is invalid." + ), +}; + +const invalidTaxDefinitionAllowedSurchargeCodesRule: ErrorToApiRule = { + priority: 120, + matches: isInvalidTaxDefinitionAllowedSurchargeCodesError, + build: (error) => + new ValidationApiError( + (error as InvalidTaxDefinitionAllowedSurchargeCodesError).message || + "Tax definition allowed surcharge codes are invalid." + ), +}; + +const invalidTaxDefinitionValidityPeriodRule: ErrorToApiRule = { + priority: 120, + matches: isInvalidTaxDefinitionValidityPeriodError, + build: (error) => + new ValidationApiError( + (error as InvalidTaxDefinitionValidityPeriodError).message || + "Tax definition validity period is invalid." + ), +}; + +const taxDefinitionNotFoundRule: ErrorToApiRule = { + priority: 120, + matches: (error) => + error instanceof EntityNotFoundError && + ((error as EntityNotFoundError).message.includes("TaxDefinition") || + (error as EntityNotFoundError).message.includes("Tax definition")), + build: (error) => + new NotFoundApiError((error as EntityNotFoundError).message || "Tax definition not found."), +}; + +const taxDefinitionCannotBeDisabledRule: ErrorToApiRule = { + priority: 120, + matches: isTaxDefinitionCannotBeDisabledError, + build: (error) => + new ValidationApiError( + (error as TaxDefinitionCannotBeDisabledError).message || "Tax definition cannot be disabled." + ), +}; + +const taxDefinitionCannotBeEnabledRule: ErrorToApiRule = { + priority: 120, + matches: isTaxDefinitionCannotBeEnabledError, + build: (error) => + new ValidationApiError( + (error as TaxDefinitionCannotBeEnabledError).message || "Tax definition cannot be enabled." + ), +}; + +export const taxDefinitionsApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default() + .register(invalidTaxDefinitionIdRule) + .register(invalidTaxDefinitionCodeRule) + .register(invalidTaxDefinitionNameRule) + .register(invalidTaxDefinitionDescriptionRule) + .register(invalidTaxDefinitionRateRule) + .register(invalidTaxDefinitionFamilyRule) + .register(invalidTaxDefinitionCalculationBehaviorRule) + .register(invalidTaxDefinitionScopeRule) + .register(invalidTaxDefinitionInvoiceNoteRule) + .register(invalidTaxDefinitionAllowedSurchargeCodesRule) + .register(invalidTaxDefinitionValidityPeriodRule) + .register(taxDefinitionNotFoundRule) + .register(taxDefinitionCannotBeDisabledRule) + .register(taxDefinitionCannotBeEnabledRule); diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/express/tax-definitions.routes.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/express/tax-definitions.routes.ts new file mode 100644 index 00000000..186de34e --- /dev/null +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/express/tax-definitions.routes.ts @@ -0,0 +1,120 @@ +import { mockUser, requireAuthenticated, requireCompanyContext } from "@erp/auth/api"; +import { type RequestWithAuth, type StartParams, validateRequest } from "@erp/core/api"; +import type { NextFunction, Request, Response } from "express"; +import { Router } from "express"; + +import { + CreateTaxDefinitionRequestSchema, + DeleteTaxDefinitionByIdRequestSchema, + GetTaxDefinitionByIdRequestSchema, + ListTaxDefinitionsRequestSchema, + UpdateTaxDefinitionByIdParamsRequestSchema, + UpdateTaxDefinitionByIdRequestSchema, +} from "../../../../common"; +import type { CatalogsInternalDeps } from "../../di"; + +import { + CreateTaxDefinitionController, + DeleteTaxDefinitionByIdController, + DisableTaxDefinitionByIdController, + EnableTaxDefinitionByIdController, + GetTaxDefinitionByIdController, + ListTaxDefinitionsController, + UpdateTaxDefinitionByIdController, +} from "./controllers"; + +export const taxDefinitionsRouter = (params: StartParams) => { + const { app, config, getInternal } = params; + const deps = getInternal("catalogs").taxDefinitions; + + const router = Router({ mergeParams: true }); + + if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "production") { + router.use((req: Request, res: Response, next: NextFunction) => + mockUser(req as RequestWithAuth, res, next) + ); + } + + router.use([ + (req: Request, res: Response, next: NextFunction) => + requireAuthenticated()(req as RequestWithAuth, res, next), + (req: Request, res: Response, next: NextFunction) => + requireCompanyContext()(req as RequestWithAuth, res, next), + ]); + + router.get( + "/", + validateRequest(ListTaxDefinitionsRequestSchema, "query"), + (req, res, next) => { + const controller = new ListTaxDefinitionsController(deps.useCases.listTaxDefinitions()); + return controller.execute(req, res, next); + } + ); + + router.post( + "/", + validateRequest(CreateTaxDefinitionRequestSchema, "body"), + (req, res, next) => { + const controller = new CreateTaxDefinitionController(deps.useCases.createTaxDefinition()); + return controller.execute(req, res, next); + } + ); + + router.get( + "/:tax_definition_id", + validateRequest(GetTaxDefinitionByIdRequestSchema, "params"), + (req, res, next) => { + const controller = new GetTaxDefinitionByIdController( + deps.useCases.getTaxDefinitionById() + ); + return controller.execute(req, res, next); + } + ); + + router.delete( + "/:tax_definition_id", + validateRequest(DeleteTaxDefinitionByIdRequestSchema, "params"), + (req, res, next) => { + const controller = new DeleteTaxDefinitionByIdController( + deps.useCases.deleteTaxDefinitionById() + ); + return controller.execute(req, res, next); + } + ); + + router.put( + "/:tax_definition_id", + validateRequest(UpdateTaxDefinitionByIdParamsRequestSchema, "params"), + validateRequest(UpdateTaxDefinitionByIdRequestSchema, "body"), + (req, res, next) => { + const controller = new UpdateTaxDefinitionByIdController( + deps.useCases.updateTaxDefinitionById() + ); + return controller.execute(req, res, next); + } + ); + + router.patch( + "/:tax_definition_id/disable", + validateRequest(GetTaxDefinitionByIdRequestSchema, "params"), + (req, res, next) => { + const controller = new DisableTaxDefinitionByIdController( + deps.useCases.disableTaxDefinitionById() + ); + return controller.execute(req, res, next); + } + ); + + router.patch( + "/:tax_definition_id/enable", + validateRequest(GetTaxDefinitionByIdRequestSchema, "params"), + (req, res, next) => { + const controller = new EnableTaxDefinitionByIdController( + deps.useCases.enableTaxDefinitionById() + ); + return controller.execute(req, res, next); + } + ); + + app.use(`${config.server.apiBasePath}/catalogs/tax-definitions`, router); +}; diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/index.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/index.ts index 96e04610..d1dab3b3 100644 --- a/modules/catalogs/src/api/infrastructure/tax-definitions/index.ts +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/index.ts @@ -1,2 +1,3 @@ +export * from "./express"; export * from "./di"; export * from "./persistence"; diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/mappers/sequelize-tax-definition-domain.mapper.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/mappers/sequelize-tax-definition-domain.mapper.ts index f7469a5e..93910d08 100644 --- a/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/mappers/sequelize-tax-definition-domain.mapper.ts +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/mappers/sequelize-tax-definition-domain.mapper.ts @@ -1,5 +1,7 @@ -import { SequelizeQueryMapper } from "@erp/core/api"; +import { type MapperParamsType, SequelizeDomainMapper } from "@erp/core/api"; import { + CountryCode, + CountryRegionCode, TextValue, UniqueID, ValidationErrorCollection, @@ -15,18 +17,20 @@ import { TaxDefinitionCode as TaxDefinitionCodeVO, TaxDefinitionName as TaxDefinitionNameVO, TaxFamily, - TaxJurisdictionCountryCode, - TaxJurisdictionRegionCode, TaxRate as TaxRateVO, TaxScope, } from "../../../../../domain"; -import type { TaxDefinitionModel } from "../models"; +import type { TaxDefinitionCreationAttributes, TaxDefinitionModel } from "../models"; -export class SequelizeTaxDefinitionDomainMapper extends SequelizeQueryMapper< +export class SequelizeTaxDefinitionDomainMapper extends SequelizeDomainMapper< TaxDefinitionModel, + TaxDefinitionCreationAttributes, TaxDefinition > { - public mapToDomain(raw: TaxDefinitionModel): Result { + public mapToDomain( + raw: TaxDefinitionModel, + params?: MapperParamsType + ): Result { const errors: ValidationErrorDetail[] = []; const companyId = extractOrPushError(UniqueID.create(raw.company_id), "company_id", errors); @@ -51,13 +55,13 @@ export class SequelizeTaxDefinitionDomainMapper extends SequelizeQueryMapper< ); const jurisdictionCountryCode = extractOrPushError( - TaxJurisdictionCountryCode.create(raw.jurisdiction_country_code), + CountryCode.create(raw.jurisdiction_country_code), "jurisdiction_country_code", errors ); const jurisdictionRegionCode = maybeFromNullableResult(raw.jurisdiction_region_code, (v) => - TaxJurisdictionRegionCode.create(v) + CountryRegionCode.create(v) ); const taxScope = extractOrPushError(TaxScope.create(raw.tax_scope), "tax_scope", errors); @@ -69,9 +73,10 @@ export class SequelizeTaxDefinitionDomainMapper extends SequelizeQueryMapper< const arr: any[] = []; for (const el of v) { - const r = TaxDefinitionCodeVO.create(String(el)); - if (r.isFailure) return Result.fail(new Error("Invalid allowed_surcharge_codes element")); - arr.push(r.getValue()); + const _result = TaxDefinitionCodeVO.create(String(el)); + if (_result.isFailure) + return Result.fail(new Error("Invalid allowed_surcharge_codes element")); + arr.push(_result.data); } return Result.ok(arr); @@ -112,46 +117,51 @@ export class SequelizeTaxDefinitionDomainMapper extends SequelizeQueryMapper< return Result.ok(domainOrError); } - public mapToPersistence(domain: TaxDefinition): Result, Error> { - const dto: Record = { - id: domain.id.toPrimitive(), - company_id: domain.companyId.toPrimitive(), - code: domain.code.toPrimitive(), - name: domain.name.toPrimitive(), - description: domain.description.match( + public mapToPersistence( + source: TaxDefinition, + params?: MapperParamsType + ): Result { + const dto: TaxDefinitionCreationAttributes = { + id: source.id.toPrimitive(), + company_id: source.companyId.toPrimitive(), + code: source.code.toPrimitive(), + name: source.name.toPrimitive(), + description: source.description.match( (v) => v.toPrimitive(), () => null ), - rate_value: domain.rate.toPrimitive(), - rate_scale: (domain.rate as any).scale ?? 2, - tax_family: domain.taxFamily.toPrimitive(), - calculation_behavior: domain.calculationBehavior.toPrimitive(), - jurisdiction_country_code: domain.jurisdictionCountryCode.toPrimitive(), - jurisdiction_region_code: domain.jurisdictionRegionCode.match( + rate_value: source.rate.toPrimitive(), + rate_scale: (source.rate as any).scale ?? 2, + tax_family: source.taxFamily.toPrimitive(), + calculation_behavior: source.calculationBehavior.toPrimitive(), + jurisdiction_country_code: source.jurisdictionCountryCode.toPrimitive(), + jurisdiction_region_code: source.jurisdictionRegionCode.match( (v) => v.toPrimitive(), () => null ), - tax_scope: domain.taxScope.toPrimitive(), - invoice_note: domain.invoiceNote.match( + tax_scope: source.taxScope.toPrimitive(), + invoice_note: source.invoiceNote.match( (v) => v.toPrimitive(), () => null ), - allowed_surcharge_codes: domain.allowedSurchargeCodes.match( + allowed_surcharge_codes: source.allowedSurchargeCodes.match( (arr) => arr.map((c) => c.toPrimitive()), () => null ), - is_system: domain.isSystem, - is_active: domain.isActive, - valid_from: domain.validFrom.match( + is_system: source.isSystem, + is_active: source.isActive, + valid_from: source.validFrom.match( (d) => d.toPrimitive(), () => null ), - valid_to: domain.validTo.match( + valid_to: source.validTo.match( (d) => d.toPrimitive(), () => null ), }; - return Result.ok(dto); + return Result.ok( + dto satisfies TaxDefinitionCreationAttributes + ); } } diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/models/index.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/models/index.ts new file mode 100644 index 00000000..144c6f5c --- /dev/null +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/models/index.ts @@ -0,0 +1 @@ +export * from "./sequelize-tax-definition.model"; diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/repositories/sequelize-tax-definition.repository.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/repositories/sequelize-tax-definition.repository.ts index 05d16ce6..7aa65a2e 100644 --- a/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/repositories/sequelize-tax-definition.repository.ts +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/repositories/sequelize-tax-definition.repository.ts @@ -5,9 +5,9 @@ import { translateSequelizeError, } from "@erp/core/api"; import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server"; -import type { UniqueID } from "@repo/rdx-ddd"; -import { type Collection, Result } from "@repo/rdx-utils"; -import type { Sequelize, Transaction } from "sequelize"; +import type { UniqueID, UtcDate } from "@repo/rdx-ddd"; +import { Collection, Maybe, Result } from "@repo/rdx-utils"; +import { Op, type Sequelize, type Transaction, type WhereOptions } from "sequelize"; import type { ITaxDefinitionRepository, TaxDefinitionSummary } from "../../../../../application"; import type { TaxDefinition, TaxDefinitionCode } from "../../../../../domain"; @@ -123,6 +123,62 @@ export class SequelizeTaxDefinitionRepository } } + async findActiveByCodeInCompany(params: { + companyId: UniqueID; + code: string; + atDate: UtcDate; + transaction?: Transaction; + }): Promise, Error>> { + try { + const row = await TaxDefinitionModel.findOne({ + where: { + ...this.buildActiveWhereClause(params.companyId, params.atDate), + code: this.normalizeCode(params.code), + }, + transaction: params.transaction, + }); + + if (!row) { + return Result.ok(Maybe.none()); + } + + return this.domainMapper.mapToDomain(row).map((taxDefinition) => Maybe.some(taxDefinition)); + } catch (err: unknown) { + return Result.fail(translateSequelizeError(err)); + } + } + + async findActiveByCodesInCompany(params: { + companyId: UniqueID; + codes: Collection; + atDate: UtcDate; + transaction?: Transaction; + }): Promise, Error>> { + try { + const normalizedCodes = Array.from( + new Set(params.codes.getAll().map((code) => this.normalizeCode(code))) + ); + + if (normalizedCodes.length === 0) { + return Result.ok(new Collection([])); + } + + const rows = await TaxDefinitionModel.findAll({ + where: { + ...this.buildActiveWhereClause(params.companyId, params.atDate), + code: { + [Op.in]: normalizedCodes, + }, + }, + transaction: params.transaction, + }); + + return this.domainMapper.mapToDomainCollection(rows, rows.length); + } catch (err: unknown) { + return Result.fail(translateSequelizeError(err)); + } + } + async findByCriteriaInCompany( companyId: UniqueID, criteria: Criteria, @@ -171,4 +227,25 @@ export class SequelizeTaxDefinitionRepository return Result.fail(translateSequelizeError(err)); } } + + private buildActiveWhereClause(companyId: UniqueID, atDate: UtcDate): WhereOptions { + const atDateISO = atDate.toPrimitive(); + + return { + company_id: companyId.toString(), + is_active: true, + [Op.and]: [ + { + [Op.or]: [{ valid_from: null }, { valid_from: { [Op.lte]: atDateISO } }], + }, + { + [Op.or]: [{ valid_to: null }, { valid_to: { [Op.gte]: atDateISO } }], + }, + ], + }; + } + + private normalizeCode(code: string): string { + return code.trim().toLowerCase(); + } } diff --git a/modules/catalogs/src/api/infrastructure/tax-regimes/di/tax-regimes.di.ts b/modules/catalogs/src/api/infrastructure/tax-regimes/di/tax-regimes.di.ts index 7e110bff..2e7ed0cc 100644 --- a/modules/catalogs/src/api/infrastructure/tax-regimes/di/tax-regimes.di.ts +++ b/modules/catalogs/src/api/infrastructure/tax-regimes/di/tax-regimes.di.ts @@ -2,8 +2,7 @@ import type { ModuleParams, SetupParams } from "@erp/core/api"; import { buildTransactionManager } from "@erp/core/api"; import type { Sequelize } from "sequelize"; -import type { ITaxRegimeRepository } from "../../../application/"; -import { TaxRegimeFinder } from "../../../application/"; +import { type ITaxRegimeRepository, TaxRegimePublicFinder } from "../../../application/"; import { CreateTaxRegimeUseCase, DeleteTaxRegimeByIdUseCase, @@ -11,6 +10,7 @@ import { EnableTaxRegimeByIdUseCase, GetTaxRegimeByIdUseCase, ListTaxRegimesUseCase, + TaxRegimePublicModelMapper, UpdateTaxRegimeByIdUseCase, } from "../../../application/tax-regimes"; import { @@ -112,8 +112,13 @@ export const buildTaxRegimesDependencies = (params: ModuleParams): TaxRegimesInt export const buildTaxRegimesPublicServices = ( _params: SetupParams, deps: TaxRegimesInternalDeps -): { finder: TaxRegimeFinder } => { +): { finder: TaxRegimePublicFinder } => { + const mapper = new TaxRegimePublicModelMapper(); + return { - finder: new TaxRegimeFinder(deps.repository), + finder: new TaxRegimePublicFinder({ + repository: deps.repository, + mapper, + }), }; }; diff --git a/modules/catalogs/src/api/infrastructure/tax-regimes/persistence/sequelize/models/sequelize-tax-regime.model.ts b/modules/catalogs/src/api/infrastructure/tax-regimes/persistence/sequelize/models/sequelize-tax-regime.model.ts index ac3875e4..07fdf017 100644 --- a/modules/catalogs/src/api/infrastructure/tax-regimes/persistence/sequelize/models/sequelize-tax-regime.model.ts +++ b/modules/catalogs/src/api/infrastructure/tax-regimes/persistence/sequelize/models/sequelize-tax-regime.model.ts @@ -37,7 +37,7 @@ export default (database: Sequelize) => { allowNull: false, }, code: { - type: DataTypes.STRING, + type: DataTypes.STRING(2), allowNull: false, unique: true, }, diff --git a/modules/catalogs/src/api/infrastructure/tax-regimes/persistence/sequelize/repositories/sequelize-tax-regime.repository.ts b/modules/catalogs/src/api/infrastructure/tax-regimes/persistence/sequelize/repositories/sequelize-tax-regime.repository.ts index 25be1046..3ac7dfb7 100644 --- a/modules/catalogs/src/api/infrastructure/tax-regimes/persistence/sequelize/repositories/sequelize-tax-regime.repository.ts +++ b/modules/catalogs/src/api/infrastructure/tax-regimes/persistence/sequelize/repositories/sequelize-tax-regime.repository.ts @@ -6,8 +6,8 @@ import { } from "@erp/core/api"; import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server"; import type { UniqueID } from "@repo/rdx-ddd"; -import { type Collection, Result } from "@repo/rdx-utils"; -import type { Sequelize, Transaction } from "sequelize"; +import { Collection, Maybe, Result } from "@repo/rdx-utils"; +import { Op, type Sequelize, type Transaction } from "sequelize"; import type { ITaxRegimeRepository, TaxRegimeSummary } from "../../../../../application"; import type { TaxRegime, TaxRegimeCode } from "../../../../../domain"; @@ -105,6 +105,22 @@ export class SequelizeTaxRegimeRepository } } + async existsByCodeInCompany( + companyId: UniqueID, + code: TaxRegimeCode, + transaction?: Transaction + ): Promise> { + try { + const count = await TaxRegimeModel.count({ + where: { code: code.toString(), company_id: companyId.toString() }, + transaction, + }); + return Result.ok(Boolean(count > 0)); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + /** * Recupera un método de pago por su ID y companyId. * @@ -250,4 +266,55 @@ export class SequelizeTaxRegimeRepository return Result.fail(translateSequelizeError(err)); } } + + async findByCodeInCompany( + companyId: UniqueID, + code: TaxRegimeCode, + transaction?: Transaction + ): Promise, Error>> { + try { + const row = await TaxRegimeModel.findOne({ + where: { code: code.toString(), company_id: companyId.toString() }, + transaction, + }); + + if (!row) { + return Result.ok(Maybe.none()); + } + + return this.domainMapper.mapToDomain(row).map((taxDefinition) => Maybe.some(taxDefinition)); + } catch (err: unknown) { + return Result.fail(translateSequelizeError(err)); + } + } + + async findByCodesInCompany(params: { + companyId: UniqueID; + codes: Collection; + transaction?: Transaction; + }): Promise, Error>> { + try { + const normalizedCodes = Array.from( + new Set(params.codes.getAll().map((code) => code.toString())) + ); + + if (normalizedCodes.length === 0) { + return Result.ok(new Collection()); + } + + const rows = await TaxRegimeModel.findAll({ + where: { + company_id: params.companyId.toString(), + code: { + [Op.in]: normalizedCodes, + }, + }, + transaction: params.transaction, + }); + + return this.domainMapper.mapToDomainCollection(rows, rows.length); + } catch (err: unknown) { + return Result.fail(translateSequelizeError(err)); + } + } } diff --git a/modules/catalogs/src/common/dto/index.ts b/modules/catalogs/src/common/dto/index.ts index 23f8279d..1702efcf 100644 --- a/modules/catalogs/src/common/dto/index.ts +++ b/modules/catalogs/src/common/dto/index.ts @@ -1,3 +1,4 @@ export * from "./payment-methods"; export * from "./payment-terms"; +export * from "./tax-definitions"; export * from "./tax-regimes"; diff --git a/modules/catalogs/src/common/dto/tax-definitions/index.ts b/modules/catalogs/src/common/dto/tax-definitions/index.ts new file mode 100644 index 00000000..32497d2a --- /dev/null +++ b/modules/catalogs/src/common/dto/tax-definitions/index.ts @@ -0,0 +1,3 @@ +export * from "./request"; +export * from "./response"; +export * from "./shared"; diff --git a/modules/catalogs/src/common/dto/tax-definitions/request/create-tax-definition.request.dto.ts b/modules/catalogs/src/common/dto/tax-definitions/request/create-tax-definition.request.dto.ts new file mode 100644 index 00000000..f4ce0f3a --- /dev/null +++ b/modules/catalogs/src/common/dto/tax-definitions/request/create-tax-definition.request.dto.ts @@ -0,0 +1,43 @@ +import { IsoDateSchema, PercentageSchema } from "@erp/core"; +import { z } from "zod/v4"; + +const TaxDefinitionCodeSchema = z.string().regex(/^[a-z0-9][a-z0-9_]*$/); +const TaxDefinitionFamilySchema = z.enum([ + "iva", + "igic", + "ipsi", + "equivalence_surcharge", + "withholding", + "vat", + "gst", + "sales_tax", + "reverse_charge", + "exempt", + "not_subject", + "custom", +]); +const TaxCalculationBehaviorSchema = z.enum(["additive", "subtractive", "neutral"]); +const TaxScopeSchema = z.enum(["domestic", "intra_eu", "export", "import", "international"]); +const JurisdictionCountryCodeSchema = z.string().regex(/^[A-Z]{2}$/); +const JurisdictionRegionCodeSchema = z.string().regex(/^[A-Z]{2}-[A-Z0-9-]+$/); + +export const CreateTaxDefinitionRequestSchema = z.object({ + id: z.uuid(), + code: TaxDefinitionCodeSchema, + name: z.string(), + description: z.string().nullable().optional(), + rate: PercentageSchema, + tax_family: TaxDefinitionFamilySchema, + calculation_behavior: TaxCalculationBehaviorSchema, + jurisdiction_country_code: JurisdictionCountryCodeSchema, + jurisdiction_region_code: JurisdictionRegionCodeSchema.nullable().optional(), + tax_scope: TaxScopeSchema, + invoice_note: z.string().nullable().optional(), + allowed_surcharge_codes: z.array(TaxDefinitionCodeSchema).nullable().optional(), + is_system: z.boolean().optional(), + is_active: z.boolean(), + valid_from: IsoDateSchema.nullable().optional(), + valid_to: IsoDateSchema.nullable().optional(), +}); + +export type CreateTaxDefinitionRequestDTO = z.infer; diff --git a/modules/catalogs/src/common/dto/tax-definitions/request/delete-tax-definition-by-id.request.dto.ts b/modules/catalogs/src/common/dto/tax-definitions/request/delete-tax-definition-by-id.request.dto.ts new file mode 100644 index 00000000..196609f0 --- /dev/null +++ b/modules/catalogs/src/common/dto/tax-definitions/request/delete-tax-definition-by-id.request.dto.ts @@ -0,0 +1,9 @@ +import { z } from "zod/v4"; + +export const DeleteTaxDefinitionByIdRequestSchema = z.object({ + tax_definition_id: z.uuid(), +}); + +export type DeleteTaxDefinitionByIdRequestDTO = z.infer< + typeof DeleteTaxDefinitionByIdRequestSchema +>; diff --git a/modules/catalogs/src/common/dto/tax-definitions/request/disable-tax-definition-by-id.request.dto.ts b/modules/catalogs/src/common/dto/tax-definitions/request/disable-tax-definition-by-id.request.dto.ts new file mode 100644 index 00000000..68bd12b8 --- /dev/null +++ b/modules/catalogs/src/common/dto/tax-definitions/request/disable-tax-definition-by-id.request.dto.ts @@ -0,0 +1,9 @@ +import { z } from "zod/v4"; + +export const DisableTaxDefinitionByIdRequestSchema = z.object({ + tax_definition_id: z.uuid(), +}); + +export type DisableTaxDefinitionByIdRequestDTO = z.infer< + typeof DisableTaxDefinitionByIdRequestSchema +>; diff --git a/modules/catalogs/src/common/dto/tax-definitions/request/enable-tax-definition-by-id.request.dto.ts b/modules/catalogs/src/common/dto/tax-definitions/request/enable-tax-definition-by-id.request.dto.ts new file mode 100644 index 00000000..5227fa05 --- /dev/null +++ b/modules/catalogs/src/common/dto/tax-definitions/request/enable-tax-definition-by-id.request.dto.ts @@ -0,0 +1,9 @@ +import { z } from "zod/v4"; + +export const EnableTaxDefinitionByIdRequestSchema = z.object({ + tax_definition_id: z.uuid(), +}); + +export type EnableTaxDefinitionByIdRequestDTO = z.infer< + typeof EnableTaxDefinitionByIdRequestSchema +>; diff --git a/modules/catalogs/src/common/dto/tax-definitions/request/get-tax-definition-by-id.request.dto.ts b/modules/catalogs/src/common/dto/tax-definitions/request/get-tax-definition-by-id.request.dto.ts new file mode 100644 index 00000000..d4b0082b --- /dev/null +++ b/modules/catalogs/src/common/dto/tax-definitions/request/get-tax-definition-by-id.request.dto.ts @@ -0,0 +1,7 @@ +import { z } from "zod/v4"; + +export const GetTaxDefinitionByIdRequestSchema = z.object({ + tax_definition_id: z.uuid(), +}); + +export type GetTaxDefinitionByIdRequestDTO = z.infer; diff --git a/modules/catalogs/src/common/dto/tax-definitions/request/index.ts b/modules/catalogs/src/common/dto/tax-definitions/request/index.ts new file mode 100644 index 00000000..ceebef93 --- /dev/null +++ b/modules/catalogs/src/common/dto/tax-definitions/request/index.ts @@ -0,0 +1,7 @@ +export * from "./create-tax-definition.request.dto"; +export * from "./delete-tax-definition-by-id.request.dto"; +export * from "./disable-tax-definition-by-id.request.dto"; +export * from "./enable-tax-definition-by-id.request.dto"; +export * from "./get-tax-definition-by-id.request.dto"; +export * from "./list-tax-definitions.request.dto"; +export * from "./update-tax-definition-by-id.request.dto"; diff --git a/modules/catalogs/src/common/dto/tax-definitions/request/list-tax-definitions.request.dto.ts b/modules/catalogs/src/common/dto/tax-definitions/request/list-tax-definitions.request.dto.ts new file mode 100644 index 00000000..4edae6cf --- /dev/null +++ b/modules/catalogs/src/common/dto/tax-definitions/request/list-tax-definitions.request.dto.ts @@ -0,0 +1,5 @@ +import { CriteriaSchema } from "@erp/core"; +import type { z } from "zod/v4"; + +export const ListTaxDefinitionsRequestSchema = CriteriaSchema; +export type ListTaxDefinitionsRequestDTO = z.infer; diff --git a/modules/catalogs/src/common/dto/tax-definitions/request/update-tax-definition-by-id.request.dto.ts b/modules/catalogs/src/common/dto/tax-definitions/request/update-tax-definition-by-id.request.dto.ts new file mode 100644 index 00000000..f02e160c --- /dev/null +++ b/modules/catalogs/src/common/dto/tax-definitions/request/update-tax-definition-by-id.request.dto.ts @@ -0,0 +1,26 @@ +import { IsoDateSchema, PercentageSchema } from "@erp/core"; +import { z } from "zod/v4"; + +const TaxDefinitionCodeSchema = z.string().regex(/^[a-z0-9][a-z0-9_]*$/); + +export const UpdateTaxDefinitionByIdParamsRequestSchema = z.object({ + tax_definition_id: z.uuid(), +}); + +export const UpdateTaxDefinitionByIdRequestSchema = z.object({ + name: z.string().optional(), + description: z.string().nullable().optional(), + rate: PercentageSchema.optional(), + invoice_note: z.string().nullable().optional(), + allowed_surcharge_codes: z.array(TaxDefinitionCodeSchema).nullable().optional(), + is_active: z.boolean().optional(), + valid_from: IsoDateSchema.nullable().optional(), + valid_to: IsoDateSchema.nullable().optional(), +}); + +export type UpdateTaxDefinitionByIdParamsRequestDTO = z.infer< + typeof UpdateTaxDefinitionByIdParamsRequestSchema +>; +export type UpdateTaxDefinitionByIdRequestDTO = z.infer< + typeof UpdateTaxDefinitionByIdRequestSchema +>; diff --git a/modules/catalogs/src/common/dto/tax-definitions/response/create-tax-definition.response.dto.ts b/modules/catalogs/src/common/dto/tax-definitions/response/create-tax-definition.response.dto.ts new file mode 100644 index 00000000..887fe5a2 --- /dev/null +++ b/modules/catalogs/src/common/dto/tax-definitions/response/create-tax-definition.response.dto.ts @@ -0,0 +1,6 @@ +import type { z } from "zod/v4"; + +import { TaxDefinitionDetailSchema } from "../shared"; + +export const CreateTaxDefinitionResponseSchema = TaxDefinitionDetailSchema; +export type CreateTaxDefinitionResponseDTO = z.infer; diff --git a/modules/catalogs/src/common/dto/tax-definitions/response/disable-tax-definition-by-id.response.dto.ts b/modules/catalogs/src/common/dto/tax-definitions/response/disable-tax-definition-by-id.response.dto.ts new file mode 100644 index 00000000..98026fa8 --- /dev/null +++ b/modules/catalogs/src/common/dto/tax-definitions/response/disable-tax-definition-by-id.response.dto.ts @@ -0,0 +1,8 @@ +import type { z } from "zod/v4"; + +import { TaxDefinitionDetailSchema } from "../shared"; + +export const DisableTaxDefinitionByIdResponseSchema = TaxDefinitionDetailSchema; +export type DisableTaxDefinitionByIdResponseDTO = z.infer< + typeof DisableTaxDefinitionByIdResponseSchema +>; diff --git a/modules/catalogs/src/common/dto/tax-definitions/response/enable-tax-definition-by-id.response.dto.ts b/modules/catalogs/src/common/dto/tax-definitions/response/enable-tax-definition-by-id.response.dto.ts new file mode 100644 index 00000000..120a1505 --- /dev/null +++ b/modules/catalogs/src/common/dto/tax-definitions/response/enable-tax-definition-by-id.response.dto.ts @@ -0,0 +1,8 @@ +import type { z } from "zod/v4"; + +import { TaxDefinitionDetailSchema } from "../shared"; + +export const EnableTaxDefinitionByIdResponseSchema = TaxDefinitionDetailSchema; +export type EnableTaxDefinitionByIdResponseDTO = z.infer< + typeof EnableTaxDefinitionByIdResponseSchema +>; diff --git a/modules/catalogs/src/common/dto/tax-definitions/response/get-tax-definition-by-id.response.dto.ts b/modules/catalogs/src/common/dto/tax-definitions/response/get-tax-definition-by-id.response.dto.ts new file mode 100644 index 00000000..f1df0fbe --- /dev/null +++ b/modules/catalogs/src/common/dto/tax-definitions/response/get-tax-definition-by-id.response.dto.ts @@ -0,0 +1,8 @@ +import type { z } from "zod/v4"; + +import { TaxDefinitionDetailSchema } from "../shared"; + +export const GetTaxDefinitionByIdResponseSchema = TaxDefinitionDetailSchema; +export type GetTaxDefinitionByIdResponseDTO = z.infer< + typeof GetTaxDefinitionByIdResponseSchema +>; diff --git a/modules/catalogs/src/common/dto/tax-definitions/response/index.ts b/modules/catalogs/src/common/dto/tax-definitions/response/index.ts new file mode 100644 index 00000000..823ee1aa --- /dev/null +++ b/modules/catalogs/src/common/dto/tax-definitions/response/index.ts @@ -0,0 +1,6 @@ +export * from "./create-tax-definition.response.dto"; +export * from "./disable-tax-definition-by-id.response.dto"; +export * from "./enable-tax-definition-by-id.response.dto"; +export * from "./get-tax-definition-by-id.response.dto"; +export * from "./list-tax-definitions.response.dto"; +export * from "./update-tax-definition-by-id.response.dto"; diff --git a/modules/catalogs/src/common/dto/tax-definitions/response/list-tax-definitions.response.dto.ts b/modules/catalogs/src/common/dto/tax-definitions/response/list-tax-definitions.response.dto.ts new file mode 100644 index 00000000..3d3cd221 --- /dev/null +++ b/modules/catalogs/src/common/dto/tax-definitions/response/list-tax-definitions.response.dto.ts @@ -0,0 +1,9 @@ +import { createPaginatedListSchema } from "@erp/core"; +import type { z } from "zod/v4"; + +import { TaxDefinitionSummarySchema } from "../shared"; + +export const ListTaxDefinitionsResponseSchema = createPaginatedListSchema( + TaxDefinitionSummarySchema +); +export type ListTaxDefinitionsResponseDTO = z.infer; diff --git a/modules/catalogs/src/common/dto/tax-definitions/response/update-tax-definition-by-id.response.dto.ts b/modules/catalogs/src/common/dto/tax-definitions/response/update-tax-definition-by-id.response.dto.ts new file mode 100644 index 00000000..65133716 --- /dev/null +++ b/modules/catalogs/src/common/dto/tax-definitions/response/update-tax-definition-by-id.response.dto.ts @@ -0,0 +1,8 @@ +import type { z } from "zod/v4"; + +import { TaxDefinitionDetailSchema } from "../shared"; + +export const UpdateTaxDefinitionByIdResponseSchema = TaxDefinitionDetailSchema; +export type UpdateTaxDefinitionByIdResponseDTO = z.infer< + typeof UpdateTaxDefinitionByIdResponseSchema +>; diff --git a/modules/catalogs/src/common/dto/tax-definitions/shared/index.ts b/modules/catalogs/src/common/dto/tax-definitions/shared/index.ts new file mode 100644 index 00000000..a2a9449c --- /dev/null +++ b/modules/catalogs/src/common/dto/tax-definitions/shared/index.ts @@ -0,0 +1,2 @@ +export * from "./tax-definition-detail.dto"; +export * from "./tax-definition-summary.dto"; diff --git a/modules/catalogs/src/common/dto/tax-definitions/shared/tax-definition-detail.dto.ts b/modules/catalogs/src/common/dto/tax-definitions/shared/tax-definition-detail.dto.ts new file mode 100644 index 00000000..baaa67d7 --- /dev/null +++ b/modules/catalogs/src/common/dto/tax-definitions/shared/tax-definition-detail.dto.ts @@ -0,0 +1,44 @@ +import { IsoDateSchema, PercentageSchema } from "@erp/core"; +import { z } from "zod/v4"; + +const TaxDefinitionCodeSchema = z.string().regex(/^[a-z0-9][a-z0-9_]*$/); +const TaxDefinitionFamilySchema = z.enum([ + "iva", + "igic", + "ipsi", + "equivalence_surcharge", + "withholding", + "vat", + "gst", + "sales_tax", + "reverse_charge", + "exempt", + "not_subject", + "custom", +]); +const TaxCalculationBehaviorSchema = z.enum(["additive", "subtractive", "neutral"]); +const TaxScopeSchema = z.enum(["domestic", "intra_eu", "export", "import", "international"]); +const JurisdictionCountryCodeSchema = z.string().regex(/^[A-Z]{2}$/); +const JurisdictionRegionCodeSchema = z.string().regex(/^[A-Z]{2}-[A-Z0-9-]+$/); + +export const TaxDefinitionDetailSchema = z.object({ + id: z.uuid(), + company_id: z.uuid(), + code: TaxDefinitionCodeSchema, + name: z.string(), + description: z.string().nullable(), + rate: PercentageSchema, + tax_family: TaxDefinitionFamilySchema, + calculation_behavior: TaxCalculationBehaviorSchema, + jurisdiction_country_code: JurisdictionCountryCodeSchema, + jurisdiction_region_code: JurisdictionRegionCodeSchema.nullable(), + tax_scope: TaxScopeSchema, + invoice_note: z.string().nullable(), + allowed_surcharge_codes: z.array(TaxDefinitionCodeSchema).nullable(), + is_system: z.boolean(), + is_active: z.boolean(), + valid_from: IsoDateSchema.nullable(), + valid_to: IsoDateSchema.nullable(), +}); + +export type TaxDefinitionDetailDTO = z.infer; diff --git a/modules/catalogs/src/common/dto/tax-definitions/shared/tax-definition-summary.dto.ts b/modules/catalogs/src/common/dto/tax-definitions/shared/tax-definition-summary.dto.ts new file mode 100644 index 00000000..8973a5aa --- /dev/null +++ b/modules/catalogs/src/common/dto/tax-definitions/shared/tax-definition-summary.dto.ts @@ -0,0 +1,14 @@ +import { z } from "zod/v4"; + +const TaxDefinitionCodeSchema = z.string().regex(/^[a-z0-9][a-z0-9_]*$/); + +export const TaxDefinitionSummarySchema = z.object({ + id: z.uuid(), + company_id: z.uuid(), + code: TaxDefinitionCodeSchema, + name: z.string(), + is_active: z.boolean(), + is_system: z.boolean(), +}); + +export type TaxDefinitionSummaryDTO = z.infer; diff --git a/modules/core/src/api/application/snapshot-builders/snapshot-builder.interface.ts b/modules/core/src/api/application/snapshot-builders/snapshot-builder.interface.ts index 4bae855c..9636447d 100644 --- a/modules/core/src/api/application/snapshot-builders/snapshot-builder.interface.ts +++ b/modules/core/src/api/application/snapshot-builders/snapshot-builder.interface.ts @@ -1,4 +1,4 @@ -export type ISnapshotBuilderParams = Readonly>; +export type ISnapshotBuilderParams = {}; //Readonly>; export interface ISnapshotBuilder { toOutput(source: TSource, params?: ISnapshotBuilderParams): TSnapshot; diff --git a/modules/core/src/api/domain/value-objects/tax-percentage.vo.ts b/modules/core/src/api/domain/value-objects/tax-percentage.vo.ts index 6ac212f2..a601d334 100644 --- a/modules/core/src/api/domain/value-objects/tax-percentage.vo.ts +++ b/modules/core/src/api/domain/value-objects/tax-percentage.vo.ts @@ -1,19 +1,25 @@ import { Percentage, type PercentageProps } from "@repo/rdx-ddd"; -import type { Result } from "@repo/rdx-utils"; +import { Result } from "@repo/rdx-utils"; type TaxPercentageProps = Pick; export class TaxPercentage extends Percentage { - static DEFAULT_SCALE = 2; + public static readonly DEFAULT_SCALE = 2; - static create({ value }: TaxPercentageProps): Result { - return Percentage.create({ + public static create({ value }: TaxPercentageProps): Result { + const result = Percentage.create({ value, scale: TaxPercentage.DEFAULT_SCALE, }); + + if (result.isFailure) { + return result; + } + + return Result.ok(new TaxPercentage(result.data.getProps())); } - static zero() { + public static zero(): TaxPercentage { return TaxPercentage.create({ value: 0 }).data; } } diff --git a/modules/core/src/api/domain/value-objects/tax.vo.ts b/modules/core/src/api/domain/value-objects/tax.vo.ts index 609ced39..8e32c7a3 100644 --- a/modules/core/src/api/domain/value-objects/tax.vo.ts +++ b/modules/core/src/api/domain/value-objects/tax.vo.ts @@ -1,178 +1,183 @@ -import type { TaxCatalogProvider } from "@erp/core"; import { ValueObject } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import { z } from "zod/v4"; -import { TaxPercentage } from "./tax-percentage.vo"; +import { TaxPercentage } from "./tax-percentage.vo.js"; -const TAX_GROUPS = ["IVA", "IPSI", "IGIC", "retention", "rec"] as const; -type TaxGroup = (typeof TAX_GROUPS)[number]; +export const TAX_GROUPS = ["iva", "ipsi", "igic", "retention", "surcharge"] as const; + +export type TaxGroup = (typeof TAX_GROUPS)[number]; + +export type TaxCalculationBehavior = "additive" | "subtractive"; export interface TaxProps { - code: string; // iva_21 - name: string; // 21% IVA - value: number; // 2100 + code: string; + name: string; + rate: TaxPercentage; group: TaxGroup; + calculationBehavior: TaxCalculationBehavior; +} + +export interface CreateTaxProps { + code: string; + name: string; + rate: TaxPercentage; + group: TaxGroup; + calculationBehavior: TaxCalculationBehavior; } export class Tax extends ValueObject { - static readonly DEFAULT_SCALE = TaxPercentage.DEFAULT_SCALE; - static readonly MIN_VALUE = TaxPercentage.MIN_VALUE; - static readonly MAX_VALUE = TaxPercentage.MAX_VALUE; - static readonly MIN_SCALE = TaxPercentage.MIN_SCALE; - static readonly MAX_SCALE = TaxPercentage.MAX_SCALE; + public static readonly DEFAULT_SCALE = TaxPercentage.DEFAULT_SCALE; + public static readonly MIN_VALUE = TaxPercentage.MIN_VALUE; + public static readonly MAX_VALUE = TaxPercentage.MAX_VALUE; + public static readonly MIN_SCALE = TaxPercentage.MIN_SCALE; + public static readonly MAX_SCALE = TaxPercentage.MAX_SCALE; - private static CODE_REGEX = /^[a-z0-9_:-]+$/; + private static readonly CODE_REGEX = /^[a-z0-9_:-]+$/; - private _percentage!: TaxPercentage; + public static create(props: CreateTaxProps): Result { + const validationResult = Tax.validate(props); - protected static validate(values: TaxProps) { + if (!validationResult.success) { + return Result.fail( + new Error(validationResult.error.issues.map((issue) => issue.message).join(", ")) + ); + } + + return Result.ok( + new Tax({ + code: props.code.trim(), + name: props.name.trim(), + rate: props.rate, + group: props.group, + calculationBehavior: props.calculationBehavior, + }) + ); + } + + protected static validate(values: CreateTaxProps) { const schema = z.object({ - value: z - .number() - .int() - .min(Tax.MIN_VALUE, "La tasa de impuesto no puede ser negativa.") - .max(Tax.MAX_VALUE * 10 ** Tax.MAX_SCALE, "La tasa de impuesto es demasiado alta."), name: z .string() + .trim() .min(1, "El nombre del impuesto es obligatorio.") .max(100, "El nombre del impuesto no puede exceder 100 caracteres."), + code: z .string() + .trim() .min(1, "El código del impuesto es obligatorio.") .max(40, "El código del impuesto no puede exceder 40 caracteres.") .regex(Tax.CODE_REGEX, "El código contiene caracteres no permitidos."), - group: z.enum(TAX_GROUPS, "El impuesto debe ser un IVA, retención o rec. equivalencia"), + + group: z.enum(TAX_GROUPS), + + calculationBehavior: z.enum(["additive", "subtractive"]), }); return schema.safeParse(values); } - static create(props: TaxProps): Result { - const { value, name, code, group } = props; - - const validationResult = Tax.validate({ value, name, code, group }); - if (!validationResult.success) { - return Result.fail(new Error(validationResult.error.issues.map((e) => e.message).join(", "))); - } - - return Result.ok(new Tax({ value, name, code, group })); - } - - /** - * Crea un Tax usando solo el 'code', resolviendo el resto de datos desde el catálogo. - * @param code Código del impuesto (p.ej. "iva_21") - * @param provider Proveedor del catálogo de impuestos - */ - static createFromCode(code: string, provider: TaxCatalogProvider): Result { - const normalized = (code ?? "").trim().toLowerCase(); - - const schema = z - .string() - .min(1, "El código del impuesto es obligatorio.") - .max(40, "El código del impuesto no puede exceder 40 caracteres.") - .regex(Tax.CODE_REGEX, "El código contiene caracteres no permitidos."); - - const validationResult = schema.safeParse(normalized); - - if (!validationResult.success) { - return Result.fail(new Error(validationResult.error.issues.map((e) => e.message).join(", "))); - } - - const maybeItem = provider.findByCode(normalized); - if (maybeItem.isNone()) { - return Result.fail( - new Error(`Código de impuesto no encontrado en el catálogo: "${normalized}"`) - ); - } - - const item = maybeItem.unwrap(); - - // Delegamos en create para reusar validación y límites - return Tax.create({ - value: Number(item.value), - name: item.name, - code: item.code, // guardamos el code tal cual viene del catálogo - group: item.group as TaxGroup, - }); - } - - get value(): number { - return this.props.value; - } - get scale(): number { - return Tax.DEFAULT_SCALE; - } - get name(): string { - return this.props.name; - } - get code(): string { + public get code(): string { return this.props.code; } - get group(): string { + public get name(): string { + return this.props.name; + } + + public get rate(): TaxPercentage { + return this.props.rate; + } + + public get group(): TaxGroup { return this.props.group; } - get percentage(): TaxPercentage { - return TaxPercentage.create({ value: this.value }).data; + public get calculationBehavior(): TaxCalculationBehavior { + return this.props.calculationBehavior; } - isVATLike(): boolean { - return this.group === "IVA" || this.group === "IGIC" || this.group === "IPSI"; + public get value(): number { + return this.props.rate.value; } - isRetention(): boolean { - return this.group === "retention"; + public get scale(): number { + return this.props.rate.scale; } - isRec(): boolean { - return this.group === "rec"; + public get percentage(): TaxPercentage { + return this.props.rate; } - getProps(): TaxProps { + public isAdditive(): boolean { + return this.props.calculationBehavior === "additive"; + } + + public isSubtractive(): boolean { + return this.props.calculationBehavior === "subtractive"; + } + + public isIva(): boolean { + return this.props.group === "iva"; + } + + public isIgic(): boolean { + return this.props.group === "igic"; + } + + public isIpsi(): boolean { + return this.props.group === "ipsi"; + } + + public isSurcharge(): boolean { + return this.props.group === "surcharge"; + } + + public isRetention(): boolean { + return this.props.group === "retention"; + } + + public getProps(): TaxProps { return this.props; } - toPrimitive() { + public toPrimitive(): TaxProps { return this.getProps(); } - toNumber(): number { + public toNumber(): number { return this.value / 10 ** this.scale; } - toString(): string { - return `${this.toNumber().toFixed(this.scale)}%`; + public toString(): string { + return this.rate.toString(); } - isZero(): boolean { + public isZero(): boolean { return this.value === 0; } - isPositive(): boolean { + public isPositive(): boolean { return this.value > 0; } - equalsTo(other: Tax): boolean { - return this.equals(other); + public equalsTo(other: Tax): boolean { + return ( + this.code === other.code && + this.name === other.name && + this.value === other.value && + this.scale === other.scale && + this.group === other.group && + this.calculationBehavior === other.calculationBehavior + ); } - greaterThan(other: Tax): boolean { + + public greaterThan(other: Tax): boolean { return this.toNumber() > other.toNumber(); } - lessThan(other: Tax): boolean { + public lessThan(other: Tax): boolean { return this.toNumber() < other.toNumber(); } - - toJSON() { - return { - value: this.value, - scale: this.scale, - name: this.name, - code: this.code, - percentage: this.toNumber(), - formatted: this.toString(), - }; - } } diff --git a/modules/core/src/api/infrastructure/di/catalogs.di.ts b/modules/core/src/api/infrastructure/di/catalogs.di.ts index e98d2b17..ca7689f2 100644 --- a/modules/core/src/api/infrastructure/di/catalogs.di.ts +++ b/modules/core/src/api/infrastructure/di/catalogs.di.ts @@ -1,21 +1,16 @@ -import { - FactuGESPaymentCatalogProvider, - type JsonPaymentCatalogProvider, - type JsonTaxCatalogProvider, - SpainTaxCatalogProvider, -} from "../../../common"; +import { type JsonTaxCatalogProvider, SpainTaxCatalogProvider } from "../../../common"; export interface ICatalogs { taxCatalog: JsonTaxCatalogProvider; - paymentCatalog: JsonPaymentCatalogProvider; + //paymentCatalog: JsonPaymentCatalogProvider; } export const buildCatalogs = (): ICatalogs => { const taxCatalog = SpainTaxCatalogProvider(); - const paymentCatalog = FactuGESPaymentCatalogProvider(); + //const paymentCatalog = FactuGESPaymentCatalogProvider(); return { taxCatalog, - paymentCatalog, + //paymentCatalog, }; }; diff --git a/modules/core/src/api/infrastructure/express/api-error-mapper.ts b/modules/core/src/api/infrastructure/express/api-error-mapper.ts index 9b3e9596..69ae86d0 100644 --- a/modules/core/src/api/infrastructure/express/api-error-mapper.ts +++ b/modules/core/src/api/infrastructure/express/api-error-mapper.ts @@ -17,9 +17,8 @@ import { isDomainValidationError, isValidationErrorCollection, } from "@repo/rdx-ddd"; -import type { ZodError } from "zod"; +import { ZodError } from "zod"; -import { isSchemaError } from "../../../common/schemas"; import { type DocumentGenerationError, isDocumentGenerationError } from "../../application"; import { type DuplicateEntityError, @@ -46,6 +45,8 @@ import { ValidationApiError, } from "./errors"; +export const isSchemaError = (e: unknown): e is ZodError => e instanceof ZodError; + // ──────────────────────────────────────────────────────────────────────────────── // Contexto opcional para enriquecer Problem+JSON (útil en middleware Express) // ──────────────────────────────────────────────────────────────────────────────── diff --git a/modules/core/src/api/infrastructure/persistence/sequelize/mappers/sequelize-domain-mapper.ts b/modules/core/src/api/infrastructure/persistence/sequelize/mappers/sequelize-domain-mapper.ts index f1212aca..4a01c84d 100644 --- a/modules/core/src/api/infrastructure/persistence/sequelize/mappers/sequelize-domain-mapper.ts +++ b/modules/core/src/api/infrastructure/persistence/sequelize/mappers/sequelize-domain-mapper.ts @@ -12,7 +12,7 @@ export abstract class SequelizeDomainMapper; + ): Result | Promise>; public mapToDomainCollection( raws: (TModel | TModelAttributes)[], diff --git a/modules/core/src/api/modules/types.ts b/modules/core/src/api/modules/types.ts index 8e3a8022..11d9c49c 100644 --- a/modules/core/src/api/modules/types.ts +++ b/modules/core/src/api/modules/types.ts @@ -6,6 +6,12 @@ export interface SequelizeModel extends Model { } export type ModuleParams = { + /** + * Acceso a servicios expuestos por otros módulos. + * Todas las dependencias ya están resueltas. + */ + getService: (serviceName: string) => T; + [key: string]: any; }; @@ -25,12 +31,6 @@ export type ModuleSetupResult = { export type SetupParams = ModuleParams; export type StartParams = ModuleParams & { - /** - * Acceso a servicios expuestos por otros módulos. - * Todas las dependencias ya están resueltas. - */ - getService: (serviceName: string) => T; - /** * Acceso a internal del propio módulo. * No debe usarse para consumir otros módulos. diff --git a/modules/core/src/common/catalogs/taxes/spain-tax-catalog.json b/modules/core/src/common/catalogs/taxes/spain-tax-catalog.json index 020bb32f..c892bd42 100644 --- a/modules/core/src/common/catalogs/taxes/spain-tax-catalog.json +++ b/modules/core/src/common/catalogs/taxes/spain-tax-catalog.json @@ -6,7 +6,8 @@ "scale": "2", "group": "IVA", "description": "IVA general. Tipo estándar nacional.", - "aeat_code": "01" + "aeat_code": "01", + "req_equals": ["rec_5_2"] }, { "name": "IVA 10%", diff --git a/modules/core/src/common/index.ts b/modules/core/src/common/index.ts index 541d6f57..2dab51a5 100644 --- a/modules/core/src/common/index.ts +++ b/modules/core/src/common/index.ts @@ -1,5 +1,4 @@ export * from "./catalogs"; export * from "./dto"; export * from "./helpers"; -export * from "./schemas"; export * from "./types"; diff --git a/modules/core/src/common/schemas/core.schemas.ts b/modules/core/src/common/schemas/core.schemas.ts deleted file mode 100644 index 38db200d..00000000 --- a/modules/core/src/common/schemas/core.schemas.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { z } from "zod"; - -/*** - * Cantidad - * – admite decimales hasta 2 cifras - * – el campo `amount` puede ser null - */ -export const makeAmountSchema = ({ maxScale = 2, nullable = false } = {}) => { - const amount = z.number().refine((v) => Number.isFinite(v), "Amount must be a finite number"); - - return z.object({ - amount: nullable ? amount.nullable() : amount, - scale: z.number().int().nonnegative().max(maxScale, `Scale cannot exceed ${maxScale}`), - }); -}; - -/** - * Porcentaje ≥ 0 ≤ 100 - * – admite decimales hasta 2 cifras - * – el campo `amount` puede ser null - */ -export const makePercentageSchema = ({ maxScale = 2, min = 0, max = 100, nullable = true } = {}) => - makeAmountSchema({ maxScale, nullable }).extend({ - amount: z - .number() - .min(min, `El porcentaje debe ser ≥ ${min}`) - .max(max, `El porcentaje no puede superar ${max}`) - .nullable(), - }); diff --git a/modules/core/src/common/schemas/index.ts b/modules/core/src/common/schemas/index.ts deleted file mode 100644 index e5649dba..00000000 --- a/modules/core/src/common/schemas/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./core.schemas"; -export * from "./schema-error"; diff --git a/modules/core/src/common/schemas/schema-error.ts b/modules/core/src/common/schemas/schema-error.ts deleted file mode 100644 index 46d4961c..00000000 --- a/modules/core/src/common/schemas/schema-error.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { ZodError } from "zod"; - -export const isSchemaError = (e: unknown): e is ZodError => e instanceof ZodError; diff --git a/modules/core/src/common/types/dto.d.ts b/modules/core/src/common/types/dto.d.ts index cad06e4f..f00d117f 100644 --- a/modules/core/src/common/types/dto.d.ts +++ b/modules/core/src/common/types/dto.d.ts @@ -1,2 +1 @@ -// biome-ignore lint/style/useNamingConvention: false positive export type DTO> = T; diff --git a/modules/customer-invoices/src/api/application/common/index.ts b/modules/customer-invoices/src/api/application/common/index.ts new file mode 100644 index 00000000..c7468d98 --- /dev/null +++ b/modules/customer-invoices/src/api/application/common/index.ts @@ -0,0 +1 @@ +export * from "./mappers"; diff --git a/modules/customer-invoices/src/api/application/common/mappers/index.ts b/modules/customer-invoices/src/api/application/common/mappers/index.ts new file mode 100644 index 00000000..cdd12e27 --- /dev/null +++ b/modules/customer-invoices/src/api/application/common/mappers/index.ts @@ -0,0 +1 @@ +export * from "./tax-definition-to-tax.mapper"; diff --git a/modules/customer-invoices/src/api/application/common/mappers/tax-definition-to-tax.mapper.ts b/modules/customer-invoices/src/api/application/common/mappers/tax-definition-to-tax.mapper.ts new file mode 100644 index 00000000..bac6589f --- /dev/null +++ b/modules/customer-invoices/src/api/application/common/mappers/tax-definition-to-tax.mapper.ts @@ -0,0 +1,44 @@ +import type { TaxDefinitionPublicFamily, TaxDefinitionPublicModel } from "@erp/catalogs/api"; +import { Tax, type TaxGroup, TaxPercentage } from "@erp/core/api"; +import { Result } from "@repo/rdx-utils"; + +/** + * Sirve para mapear TaxDefinitionPublicModel a Tax, + * que es el modelo de dominio que se para proforma e issued-invoices. + */ + +export class TaxDefinitionToTaxMapper { + public toTax(taxDefinition: TaxDefinitionPublicModel): Result { + const group = this.toTaxGroup(taxDefinition.taxFamily); + + if (group.isFailure) { + return Result.fail(group.error); + } + + return Tax.create({ + code: taxDefinition.code, + name: taxDefinition.name, + rate: TaxPercentage.create({ + value: taxDefinition.rate.value, + }).data, + group: group.data, + calculationBehavior: taxDefinition.calculationBehavior, + }); + } + + private toTaxGroup(taxFamily: TaxDefinitionPublicFamily): Result { + switch (taxFamily) { + case "iva": + case "igic": + case "ipsi": + case "retention": + return Result.ok(taxFamily); + + case "surcharge": + return Result.ok("surcharge"); + + default: + return Result.fail(new Error(`Unsupported tax family: ${String(taxFamily)}`)); + } + } +} diff --git a/modules/customer-invoices/src/api/application/common/models/index.ts b/modules/customer-invoices/src/api/application/common/models/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/customer-invoices/src/api/application/common/services/index.ts b/modules/customer-invoices/src/api/application/common/services/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/customer-invoices/src/api/application/issued-invoices/di/proforma-to-issued-invoice-props-converter.di.ts b/modules/customer-invoices/src/api/application/issued-invoices/di/proforma-to-issued-invoice-props-converter.di.ts index 3dafcc3d..33b83f05 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/di/proforma-to-issued-invoice-props-converter.di.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/di/proforma-to-issued-invoice-props-converter.di.ts @@ -1,12 +1,10 @@ -import type { ICatalogs } from "@erp/core/api"; +import { buildCatalogs } from "@erp/core/api"; import { type IProformaToIssuedInvoiceConverter, ProformaToIssuedInvoiceConverter, } from "../services"; -export function buildProformaToIssuedInvoicePropsConverter( - catalogs: ICatalogs -): IProformaToIssuedInvoiceConverter { - return new ProformaToIssuedInvoiceConverter(catalogs); +export function buildProformaToIssuedInvoicePropsConverter(): IProformaToIssuedInvoiceConverter { + return new ProformaToIssuedInvoiceConverter(buildCatalogs()); } 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 4428548f..85db04ab 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 @@ -222,7 +222,7 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic const paymentMethodOrError = InvoicePaymentMethod.create( { - paymentDescription: existingPaymentResult.unwrap().description, + name: existingPaymentResult.unwrap().description, }, paymentId ); 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 850d08a2..c70aa9aa 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 @@ -28,7 +28,7 @@ export class IssuedInvoiceFullSnapshotBuilder implements IIssuedInvoiceFullSnaps const payment_method = { payment_id: invoice.paymentMethod.id.toString(), - payment_description: invoice.paymentMethod.paymentDescription.toString(), + payment_description: invoice.paymentMethod.name.toString(), }; return { diff --git a/modules/customer-invoices/src/api/application/proformas/di/index.ts b/modules/customer-invoices/src/api/application/proformas/di/index.ts index 99d4b6f4..db4cc1c3 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/index.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/index.ts @@ -1,7 +1,10 @@ +export * from "./proforma-catalog-resolvers.di"; export * from "./proforma-creator.di"; export * from "./proforma-finder.di"; export * from "./proforma-input-mappers.di"; export * from "./proforma-issuer.di"; +export * from "./proforma-read-model-assemblers.di"; export * from "./proforma-snapshot-builders.di"; +export * from "./proforma-status-changer.di"; export * from "./proforma-updater.di"; export * from "./proforma-use-cases.di"; diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-catalog-resolvers.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-catalog-resolvers.di.ts new file mode 100644 index 00000000..85424326 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-catalog-resolvers.di.ts @@ -0,0 +1,33 @@ +import type { + IPaymentMethodPublicFinder, + ITaxDefinitionPublicFinder, + ITaxRegimePublicFinder, +} from "@erp/catalogs/api"; + +import { + ProformaPaymentResolver, + type ProformaTaxResolver, + buildProformaTaxResolver, +} from "../services"; + +export interface IProformaCatalogResolvers { + taxResolver: ProformaTaxResolver; + paymentResolver: ProformaPaymentResolver; +} + +export function buildProformaCatalogResolvers(params: { + taxDefinitionFinder: ITaxDefinitionPublicFinder; + taxRegimeFinder: ITaxRegimePublicFinder; + paymentMethodFinder: IPaymentMethodPublicFinder; +}): IProformaCatalogResolvers { + return { + taxResolver: buildProformaTaxResolver({ + taxDefinitionFinder: params.taxDefinitionFinder, + taxRegimeFinder: params.taxRegimeFinder, + }), + + paymentResolver: new ProformaPaymentResolver({ + paymentMethodFinder: params.paymentMethodFinder, + }), + }; +} diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-creator.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-creator.di.ts index c20d2565..26c8e18f 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-creator.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-creator.di.ts @@ -1,14 +1,17 @@ import type { IProformaRepository } from "../repositories"; -import { type IProformaCreator, type IProformaNumberGenerator, ProformaCreator } from "../services"; +import type { ProformaPaymentResolver } from "../services"; +import { + type IProformaCreator, + type IProformaNumberGenerator, + ProformaCreator, + type ProformaTaxResolver, +} from "../services"; export const buildProformaCreator = (params: { + taxResolver: ProformaTaxResolver; + paymentResolver: ProformaPaymentResolver; numberService: IProformaNumberGenerator; repository: IProformaRepository; }): IProformaCreator => { - const { numberService, repository } = params; - - return new ProformaCreator({ - numberService, - repository, - }); + return new ProformaCreator(params); }; diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-input-mappers.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-input-mappers.di.ts index 534520ae..2dbee9f7 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-input-mappers.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-input-mappers.di.ts @@ -1,23 +1,18 @@ -import type { ICatalogs } from "@erp/core/api"; - import { CreateProformaInputMapper, type ICreateProformaInputMapper, + type IUpdateProformaInputMapper, UpdateProformaInputMapper, } from "../mappers"; export interface IProformaInputMappers { createInputMapper: ICreateProformaInputMapper; - updateInputMapper: UpdateProformaInputMapper; + updateInputMapper: IUpdateProformaInputMapper; } -export const buildProformaInputMappers = (catalogs: ICatalogs): IProformaInputMappers => { - // Mappers el DTO a las props validadas (ProformaProps) y luego construir agregado - const createInputMapper = new CreateProformaInputMapper(catalogs); - const updateInputMapper = new UpdateProformaInputMapper(catalogs); - +export const buildProformaInputMappers = (): IProformaInputMappers => { return { - createInputMapper, - updateInputMapper, + createInputMapper: new CreateProformaInputMapper(), + updateInputMapper: new UpdateProformaInputMapper(), }; }; diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-read-model-assemblers.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-read-model-assemblers.di.ts new file mode 100644 index 00000000..32d834a9 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-read-model-assemblers.di.ts @@ -0,0 +1,19 @@ +import type { IPaymentMethodPublicFinder, ITaxRegimePublicFinder } from "@erp/catalogs/api"; + +import { type IProformaFullReadModelAssembler, ProformaFullReadModelAssembler } from "../services"; + +export interface IProformaReadModelAssemblers { + full: IProformaFullReadModelAssembler; +} + +export function buildProformaReadModelAssemblers(params: { + paymentMethodFinder: IPaymentMethodPublicFinder; + taxRegimeFinder: ITaxRegimePublicFinder; +}): IProformaReadModelAssemblers { + return { + full: new ProformaFullReadModelAssembler({ + paymentMethodFinder: params.paymentMethodFinder, + taxRegimeFinder: params.taxRegimeFinder, + }), + }; +} diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-status-changer.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-status-changer.di.ts new file mode 100644 index 00000000..d53b5595 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-status-changer.di.ts @@ -0,0 +1,8 @@ +import type { IProformaRepository } from "../repositories"; +import { type IProformaStatusChanger, ProformaStatusChanger } from "../services"; + +export const buildProformaStatusChanger = (params: { + repository: IProformaRepository; +}): IProformaStatusChanger => { + return new ProformaStatusChanger(params); +}; diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts index 5f746d44..18e0ec65 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts @@ -1,12 +1,12 @@ +// modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts import type { IProformaRepository } from "../repositories"; -import { type IProformaUpdater, ProformaUpdater } from "../services"; +import type { ProformaPaymentResolver } from "../services"; +import { type IProformaUpdater, type ProformaTaxResolver, ProformaUpdater } from "../services"; export const buildProformaUpdater = (params: { repository: IProformaRepository; + taxResolver: ProformaTaxResolver; + paymentResolver: ProformaPaymentResolver; }): IProformaUpdater => { - const { repository } = params; - - return new ProformaUpdater({ - repository, - }); + return new ProformaUpdater(params); }; 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 9eeeff21..a3330ffd 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 @@ -1,19 +1,21 @@ import type { ITransactionManager } from "@erp/core/api"; import type { IIssuedInvoicePublicServices } from "../../issued-invoices"; -import type { ICreateProformaInputMapper, UpdateProformaInputMapper } from "../mappers"; +import type { ICreateProformaInputMapper, IUpdateProformaInputMapper } from "../mappers"; import type { IProformaCreator, IProformaFinder, + IProformaFullReadModelAssembler, IProformaIssuer, + IProformaStatusChanger, IProformaUpdater, ProformaDocumentGeneratorService, } from "../services"; import type { + IProformaFullSnapshotBuilder, IProformaReportSnapshotBuilder, IProformaSummarySnapshotBuilder, } from "../snapshot-builders"; -import type { IProformaFullSnapshotBuilder } from "../snapshot-builders/full"; import { ChangeStatusProformaUseCase, CreateProformaUseCase, @@ -21,15 +23,21 @@ import { IssueProformaUseCase, ListProformasUseCase, ReportProformaUseCase, + UpdateProformaByIdUseCase, } from "../use-cases"; -import { UpdateProformaByIdUseCase } from "../use-cases/update-proforma-by-id.use-case"; export function buildGetProformaByIdUseCase(deps: { finder: IProformaFinder; + fullReadModelAssembler: IProformaFullReadModelAssembler; fullSnapshotBuilder: IProformaFullSnapshotBuilder; transactionManager: ITransactionManager; }) { - return new GetProformaByIdUseCase(deps.finder, deps.fullSnapshotBuilder, deps.transactionManager); + return new GetProformaByIdUseCase({ + finder: deps.finder, + fullReadModelAssembler: deps.fullReadModelAssembler, + fullSnapshotBuilder: deps.fullSnapshotBuilder, + transactionManager: deps.transactionManager, + }); } export function buildListProformasUseCase(deps: { @@ -46,29 +54,33 @@ export function buildListProformasUseCase(deps: { export function buildReportProformaUseCase(deps: { finder: IProformaFinder; + fullReadModelAssembler: IProformaFullReadModelAssembler; fullSnapshotBuilder: IProformaFullSnapshotBuilder; reportSnapshotBuilder: IProformaReportSnapshotBuilder; documentService: ProformaDocumentGeneratorService; transactionManager: ITransactionManager; }) { - return new ReportProformaUseCase( - deps.finder, - deps.fullSnapshotBuilder, - deps.reportSnapshotBuilder, - deps.documentService, - deps.transactionManager - ); + return new ReportProformaUseCase({ + finder: deps.finder, + fullReadModelAssembler: deps.fullReadModelAssembler, + fullSnapshotBuilder: deps.fullSnapshotBuilder, + reportSnapshotBuilder: deps.reportSnapshotBuilder, + documentService: deps.documentService, + transactionManager: deps.transactionManager, + }); } export function buildCreateProformaUseCase(deps: { creator: IProformaCreator; dtoMapper: ICreateProformaInputMapper; + fullReadModelAssembler: IProformaFullReadModelAssembler; fullSnapshotBuilder: IProformaFullSnapshotBuilder; transactionManager: ITransactionManager; }) { return new CreateProformaUseCase({ dtoMapper: deps.dtoMapper, creator: deps.creator, + fullReadModelAssembler: deps.fullReadModelAssembler, fullSnapshotBuilder: deps.fullSnapshotBuilder, transactionManager: deps.transactionManager, }); @@ -98,13 +110,15 @@ export function buildIssueProformaUseCase(deps: { export function buildUpdateProformaUseCase(deps: { updater: IProformaUpdater; - dtoMapper: UpdateProformaInputMapper; + dtoMapper: IUpdateProformaInputMapper; + fullReadModelAssembler: IProformaFullReadModelAssembler; fullSnapshotBuilder: IProformaFullSnapshotBuilder; transactionManager: ITransactionManager; }) { return new UpdateProformaByIdUseCase({ dtoMapper: deps.dtoMapper, updater: deps.updater, + fullReadModelAssembler: deps.fullReadModelAssembler, fullSnapshotBuilder: deps.fullSnapshotBuilder, transactionManager: deps.transactionManager, }); @@ -116,14 +130,14 @@ export function buildDeleteProformaUseCase(deps: { finder: IProformaFinder }) { }*/ export function buildChangeStatusProformaUseCase(deps: { - finder: IProformaFinder; - updater: IProformaUpdater; + statusChanger: IProformaStatusChanger; + fullReadModelAssembler: IProformaFullReadModelAssembler; fullSnapshotBuilder: IProformaFullSnapshotBuilder; transactionManager: ITransactionManager; }) { return new ChangeStatusProformaUseCase({ - finder: deps.finder, - updater: deps.updater, + statusChanger: deps.statusChanger, + fullReadModelAssembler: deps.fullReadModelAssembler, fullSnapshotBuilder: deps.fullSnapshotBuilder, transactionManager: deps.transactionManager, }); diff --git a/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts b/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts index 948b525b..54efaccc 100644 --- a/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts +++ b/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts @@ -1,5 +1,4 @@ -import type { JsonTaxCatalogProvider } from "@erp/core"; -import { DiscountPercentage, type ICatalogs, Tax } from "@erp/core/api"; +import { DiscountPercentage } from "@erp/core/api"; import { CurrencyCode, DomainError, @@ -16,8 +15,6 @@ import { Maybe, NumberHelper, Result } from "@repo/rdx-utils"; import type { CreateProformaRequestDTO } from "../../../../common"; import { - type IProformaCreateProps, - type IProformaItemCreateProps, InvoiceNumber, type InvoiceRecipient, InvoiceSerie, @@ -25,34 +22,32 @@ import { ItemAmount, ItemDescription, ItemQuantity, - ProformaItemTaxes, - type ProformaItemTaxesProps, } from "../../../domain"; +import type { + ProformaCreateInputProps, + ProformaItemCreateInputProps, + ProformaItemTaxCodesInput, +} from "../models"; export interface ICreateProformaInputMapper { map( dto: CreateProformaRequestDTO, params: { companyId: UniqueID } - ): Result<{ id: UniqueID; props: IProformaCreateProps }>; + ): Result<{ id: UniqueID; props: ProformaCreateInputProps }>; } /** - * @summary Convierte el DTO de creación de proforma en props de dominio. + * @summary Convierte el DTO de creación de proforma en props de aplicación. * @remarks - * No construye el agregado. Solo valida y convierte primitivas de transporte - * a Value Objects y props necesarias para `Proforma.create`. + * No construye el agregado y no resuelve impuestos contra catálogos. + * Solo valida y convierte primitivas de transporte a Value Objects o códigos + * fiscales pendientes de resolver por `ProformaCreator`. */ - export class CreateProformaInputMapper implements ICreateProformaInputMapper { - private readonly taxCatalog: JsonTaxCatalogProvider; - - constructor(catalogs: ICatalogs) { - this.taxCatalog = catalogs.taxCatalog; - } public map( dto: CreateProformaRequestDTO, params: { companyId: UniqueID } - ): Result<{ id: UniqueID; props: IProformaCreateProps }> { + ): Result<{ id: UniqueID; props: ProformaCreateInputProps }> { const errors: ValidationErrorDetail[] = []; try { @@ -132,6 +127,12 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper { errors ); + const taxRegimeCode = extractOrPushError( + maybeFromNullableResult(dto.tax_regime_code, (value) => Result.ok(String(value))), + "tax_regime_code", + errors + ); + const items = this.mapItemsProps(dto.items, { languageCode: languageCode!, currencyCode: currencyCode!, @@ -141,7 +142,7 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper { this.throwIfValidationErrors(errors); - const props: IProformaCreateProps = { + const props: ProformaCreateInputProps = { companyId: params.companyId, status: InvoiceStatus.draft(), @@ -163,6 +164,7 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper { linkedInvoiceId: Maybe.none(), + taxRegimeCode: taxRegimeCode!, paymentMethodId: paymentMethodId!, globalDiscountPercentage: globalDiscountPercentage!, @@ -186,7 +188,7 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper { globalDiscountPercentage: DiscountPercentage; errors: ValidationErrorDetail[]; } - ): IProformaItemCreateProps[] { + ): ProformaItemCreateInputProps[] { return itemsDTO.map((item, index) => { const description = extractOrPushError( maybeFromNullableResult(item.description, (value) => ItemDescription.create(value)), @@ -243,36 +245,43 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper { private mapTaxesProps( taxesDTO: CreateProformaRequestDTO["items"][number]["taxes"], params: { itemIndex: number; errors: ValidationErrorDetail[] } - ): ProformaItemTaxesProps { + ): ProformaItemTaxCodesInput { const parts = taxesDTO.split(";"); + if (parts.length !== 3) { params.errors.push({ path: `items[${params.itemIndex}].taxes`, message: "Tax combination must contain exactly three elements", }); - return ProformaItemTaxes.empty().getProps(); + + return this.emptyTaxCodes(); } const [ivaCode, recCode, retentionCode] = parts; - const iva = this.mapTaxCode(ivaCode, `items[${params.itemIndex}].taxes.iva`, params.errors); - const rec = this.mapTaxCode(recCode, `items[${params.itemIndex}].taxes.rec`, params.errors); - const retention = this.mapTaxCode( - retentionCode, - `items[${params.itemIndex}].taxes.retention`, - params.errors - ); - - return ProformaItemTaxes.create({ iva, rec, retention }).data.getProps(); + return { + ivaCode: this.mapNullableTaxCode(ivaCode), + recCode: this.mapNullableTaxCode(recCode), + retentionCode: this.mapNullableTaxCode(retentionCode), + }; } - private mapTaxCode(code: string, path: string, errors: ValidationErrorDetail[]): Maybe { - if (code === "#") { + private mapNullableTaxCode(code: string): Maybe { + const normalizedCode = code.trim(); + + if (normalizedCode === "" || normalizedCode === "#") { return Maybe.none(); } - const tax = extractOrPushError(Tax.createFromCode(code, this.taxCatalog), path, errors); - return tax ? Maybe.some(tax) : Maybe.none(); + return Maybe.some(normalizedCode); + } + + private emptyTaxCodes(): ProformaItemTaxCodesInput { + return { + ivaCode: Maybe.none(), + recCode: Maybe.none(), + retentionCode: Maybe.none(), + }; } private throwIfValidationErrors(errors: ValidationErrorDetail[]): void { diff --git a/modules/customer-invoices/src/api/application/proformas/mappers/update-proforma-input.mapper.ts b/modules/customer-invoices/src/api/application/proformas/mappers/update-proforma-input.mapper.ts index a0e365d0..c836e44a 100644 --- a/modules/customer-invoices/src/api/application/proformas/mappers/update-proforma-input.mapper.ts +++ b/modules/customer-invoices/src/api/application/proformas/mappers/update-proforma-input.mapper.ts @@ -1,5 +1,5 @@ -import type { JsonTaxCatalogProvider } from "@erp/core"; -import { DiscountPercentage, type ICatalogs, Tax } from "@erp/core/api"; +// modules/customer-invoices/src/api/application/proformas/mappers/update-proforma-input.mapper.ts +import { DiscountPercentage } from "@erp/core/api"; import { CurrencyCode, DomainError, @@ -12,28 +12,25 @@ import { extractOrPushError, maybeFromNullableResult, } from "@repo/rdx-ddd"; -import { Result, isNullishOrEmpty, toPatchField } from "@repo/rdx-utils"; +import { Maybe, Result, isNullishOrEmpty, toPatchField } from "@repo/rdx-utils"; import type { UpdateProformaByIdRequestDTO } from "../../../../common/dto"; -import { - InvoiceSerie, - ItemAmount, - ItemDescription, - ItemQuantity, - type ProformaItemPatchProps, - type ProformaItemTaxesProps, - type ProformaPatchProps, -} from "../../../domain"; +import { InvoiceSerie, ItemAmount, ItemDescription, ItemQuantity } from "../../../domain"; +import type { + ProformaItemPatchInputProps, + ProformaPatchInputProps, + ProformaItemTaxCodesInput, +} from "../models"; export interface IUpdateProformaInputMapper { map( dto: UpdateProformaByIdRequestDTO, params: { companyId: UniqueID } - ): Result; + ): Result; } /** - * @summary Convierte el DTO de update de proforma en props de dominio. + * @summary Convierte el DTO de update de proforma en props de aplicación. * @remarks * Respeta semántica PATCH en cabecera: * - omitido: no modificar @@ -44,21 +41,19 @@ export interface IUpdateProformaInputMapper { * - undefined: no tocar líneas * - []: borrar todas las líneas * - [...]: reemplazar colección completa + * + * No resuelve impuestos contra catálogos. Solo conserva los códigos fiscales + * para que `ProformaUpdater` los resuelva usando la fecha efectiva: + * `patch.invoiceDate ?? current.invoiceDate`. */ - export class UpdateProformaInputMapper implements IUpdateProformaInputMapper { - private readonly taxCatalog: JsonTaxCatalogProvider; - constructor(catalogs: ICatalogs) { - this.taxCatalog = catalogs.taxCatalog; - } - public map( dto: UpdateProformaByIdRequestDTO, _params: { companyId: UniqueID } - ): Result { + ): Result { try { const errors: ValidationErrorDetail[] = []; - const proformaPatchProps: ProformaPatchProps = {}; + const proformaPatchProps: ProformaPatchInputProps = {}; toPatchField(dto.series).ifSet((series) => { proformaPatchProps.series = extractOrPushError( @@ -196,7 +191,6 @@ export class UpdateProformaInputMapper implements IUpdateProformaInputMapper { return Result.ok(proformaPatchProps); } catch (err: unknown) { - console.error(err); return Result.fail(new DomainError("Proforma props mapping failed (update)", { cause: err })); } } @@ -204,7 +198,7 @@ export class UpdateProformaInputMapper implements IUpdateProformaInputMapper { private mapItemsProps( itemsDTO: NonNullable, params: { errors: ValidationErrorDetail[] } - ): ProformaItemPatchProps[] { + ): ProformaItemPatchInputProps[] { return itemsDTO.map((item, index) => { const description = extractOrPushError( maybeFromNullableResult(item.description, (value) => ItemDescription.create(value)), @@ -232,53 +226,37 @@ export class UpdateProformaInputMapper implements IUpdateProformaInputMapper { params.errors ); - const taxes = this.mapTaxesProps(item, { - itemIndex: index, - errors: params.errors, - }); - return { position: item.position, + description: description!, quantity: quantity!, unitAmount: unitAmount!, itemDiscountPercentage: itemDiscountPercentage!, - taxes, + + taxes: this.mapTaxesProps(item), }; }); } private mapTaxesProps( - itemDTO: NonNullable[number], - params: { itemIndex: number; errors: ValidationErrorDetail[] } - ): ProformaItemTaxesProps { - const iva = extractOrPushError( - maybeFromNullableResult(itemDTO.iva_code, (v) => Tax.createFromCode(v, this.taxCatalog)), - `items[${params.itemIndex}].iva_code`, - params.errors - ); - - const rec = extractOrPushError( - maybeFromNullableResult(itemDTO.rec_code, (v) => Tax.createFromCode(v, this.taxCatalog)), - `items[${params.itemIndex}].rec_code`, - params.errors - ); - - const retention = extractOrPushError( - maybeFromNullableResult(itemDTO.retention_code, (v) => - Tax.createFromCode(v, this.taxCatalog) - ), - `items[${params.itemIndex}].retention_code`, - params.errors - ); - + itemDTO: NonNullable[number] + ): ProformaItemTaxCodesInput { return { - iva: iva!, - rec: rec!, - retention: retention!, + ivaCode: this.mapNullableTaxCode(itemDTO.iva_code), + recCode: this.mapNullableTaxCode(itemDTO.rec_code), + retentionCode: this.mapNullableTaxCode(itemDTO.retention_code), }; } + private mapNullableTaxCode(code: string | null | undefined): Maybe { + if (isNullishOrEmpty(code)) { + return Maybe.none(); + } + + return Maybe.some(String(code).trim()); + } + private throwIfValidationErrors(errors: ValidationErrorDetail[]): void { if (errors.length > 0) { throw new ValidationErrorCollection("Proforma props mapping failed", errors); diff --git a/modules/customer-invoices/src/api/application/proformas/models/index.ts b/modules/customer-invoices/src/api/application/proformas/models/index.ts index 3030fae7..cc215b9b 100644 --- a/modules/customer-invoices/src/api/application/proformas/models/index.ts +++ b/modules/customer-invoices/src/api/application/proformas/models/index.ts @@ -1 +1,5 @@ +export * from "./proforma-create-input.model"; +export * from "./proforma-full-read.model"; +export * from "./proforma-item-tax-codes-input.model"; export * from "./proforma-summary"; +export * from "./proforma-update-input.model"; diff --git a/modules/customer-invoices/src/api/application/proformas/models/proforma-create-input.model.ts b/modules/customer-invoices/src/api/application/proformas/models/proforma-create-input.model.ts new file mode 100644 index 00000000..dfb7d6aa --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/models/proforma-create-input.model.ts @@ -0,0 +1,21 @@ +import type { IProformaCreateProps, IProformaItemCreateProps } from "../../../domain"; + +import type { ProformaItemTaxCodesInput } from "./proforma-item-tax-codes-input.model"; + +/** + * @summary Modelos de entrada para creación de proformas. + * @remarks + * Estos modelos representan la forma en que el cliente envía los datos para crear una proforma. + * Se diferencian de los modelos de dominio en que: + * - No incluyen lógica de negocio ni métodos, solo datos. + * - Pueden tener una estructura diferente a la de dominio, adaptada a las necesidades de la API. + * - Se encargan de validar y convertir los datos recibidos en formatos adecuados para el dominio. + */ + +export type ProformaCreateInputProps = Omit & { + items: ProformaItemCreateInputProps[]; +}; + +export type ProformaItemCreateInputProps = Omit & { + taxes: ProformaItemTaxCodesInput; +}; diff --git a/modules/customer-invoices/src/api/application/proformas/models/proforma-full-read.model.ts b/modules/customer-invoices/src/api/application/proformas/models/proforma-full-read.model.ts new file mode 100644 index 00000000..85305760 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/models/proforma-full-read.model.ts @@ -0,0 +1,32 @@ +import type { Maybe } from "@repo/rdx-utils"; + +import type { Proforma } from "../../../domain"; + +/** + * Datos del método de pago que completan a la proforma. + */ +export interface ProformaPaymentMethodReadModel { + id: string; + name: string; + description: Maybe; +} + +/** + * Datos del régimen de pago que completan a la proforma. + */ +export interface ProformaTaxRegimeFullReadModel { + code: string; + description: string; +} + +/** + * Modelo de una proforma con datos accesorios. + * + * Combina el agregado con datos auxiliares necesarios para + * construir la respuesta API generada por el snapshot builder. + */ +export interface ProformaFullReadModel { + proforma: Proforma; + paymentMethod: Maybe; + taxRegime: Maybe; +} diff --git a/modules/customer-invoices/src/api/application/proformas/models/proforma-item-tax-codes-input.model.ts b/modules/customer-invoices/src/api/application/proformas/models/proforma-item-tax-codes-input.model.ts new file mode 100644 index 00000000..606acc68 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/models/proforma-item-tax-codes-input.model.ts @@ -0,0 +1,7 @@ +import type { Maybe } from "@repo/rdx-utils"; + +export interface ProformaItemTaxCodesInput { + ivaCode: Maybe; + recCode: Maybe; + retentionCode: Maybe; +} diff --git a/modules/customer-invoices/src/api/application/proformas/models/proforma-update-input.model.ts b/modules/customer-invoices/src/api/application/proformas/models/proforma-update-input.model.ts new file mode 100644 index 00000000..8978e52d --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/models/proforma-update-input.model.ts @@ -0,0 +1,21 @@ +import type { ProformaItemPatchProps, ProformaPatchProps } from "../../../domain"; + +import type { ProformaItemTaxCodesInput } from "./proforma-item-tax-codes-input.model"; + +/** + * @summary Modelos de entrada para edición de proformas. + * @remarks + * Estos modelos representan la forma en que el cliente envía los datos para editar una proforma. + * Se diferencian de los modelos de dominio en que: + * - No incluyen lógica de negocio ni métodos, solo datos. + * - Pueden tener una estructura diferente a la de dominio, adaptada a las necesidades de la API. + * - Se encargan de validar y convertir los datos recibidos en formatos adecuados para el dominio. + */ + +export type ProformaPatchInputProps = Omit & { + items?: ProformaItemPatchInputProps[]; +}; + +export type ProformaItemPatchInputProps = Omit & { + taxes: ProformaItemTaxCodesInput; +}; diff --git a/modules/customer-invoices/src/api/application/proformas/services/assemblers/index.ts b/modules/customer-invoices/src/api/application/proformas/services/assemblers/index.ts new file mode 100644 index 00000000..6123aa1c --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/assemblers/index.ts @@ -0,0 +1 @@ +export * from "./proforma-full-read-model.assembler"; diff --git a/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-full-read-model.assembler.ts b/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-full-read-model.assembler.ts new file mode 100644 index 00000000..5d843313 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-full-read-model.assembler.ts @@ -0,0 +1,142 @@ +import type { IPaymentMethodPublicFinder, ITaxRegimePublicFinder } from "@erp/catalogs/api"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import type { Proforma } from "../../../../domain"; +import type { + ProformaFullReadModel, + ProformaPaymentMethodReadModel, + ProformaTaxRegimeFullReadModel, +} from "../../models"; + +export interface IProformaFullReadModelAssembler { + assemble(params: { + companyId: UniqueID; + proforma: Proforma; + transaction?: unknown; + }): Promise>; +} + +/** + * Enriquece una proforma recuperada con + * datos accesorios recuperados del catálogo. + * + * No modifica el agregado. Su responsabilidad es + * preparar el modelo completo que recibirá el + * snapshot builder después. + */ + +export class ProformaFullReadModelAssembler implements IProformaFullReadModelAssembler { + public constructor( + private readonly deps: { + paymentMethodFinder: IPaymentMethodPublicFinder; + taxRegimeFinder: ITaxRegimePublicFinder; + } + ) {} + + public async assemble(params: { + companyId: UniqueID; + proforma: Proforma; + transaction?: unknown; + }): Promise> { + const paymentMethod = await this.resolvePaymentMethod(params); + + if (paymentMethod.isFailure) { + return Result.fail(paymentMethod.error); + } + + const taxRegime = await this.resolveTaxRegime(params); + + if (taxRegime.isFailure) { + return Result.fail(taxRegime.error); + } + + return Result.ok({ + proforma: params.proforma, + paymentMethod: paymentMethod.data, + taxRegime: taxRegime.data, + }); + } + + private async resolvePaymentMethod(params: { + companyId: UniqueID; + proforma: Proforma; + transaction?: unknown; + }): Promise, Error>> { + if (params.proforma.paymentMethodId.isNone()) { + return Result.ok(Maybe.none()); + } + + const paymentMethodId = params.proforma.paymentMethodId.unwrap(); + + const result = await this.deps.paymentMethodFinder.findByIdInCompany({ + companyId: params.companyId, + id: paymentMethodId, + transaction: params.transaction, + }); + + if (result.isFailure) { + return Result.fail(result.error); + } + + if (result.data.isNone()) { + return Result.ok( + Maybe.some({ + id: paymentMethodId.toString(), + name: "", + description: Maybe.none(), + }) + ); + } + + const paymentMethod = result.data.unwrap(); + + return Result.ok( + Maybe.some({ + id: paymentMethod.id.toString(), + name: paymentMethod.name.toString(), + description: paymentMethod.description ?? null, + }) + ); + } + + private async resolveTaxRegime(params: { + companyId: UniqueID; + proforma: Proforma; + transaction?: unknown; + }): Promise, Error>> { + if (params.proforma.taxRegimeCode.isNone()) { + return Result.ok(Maybe.none()); + } + + const taxRegimeCode = params.proforma.taxRegimeCode.unwrap(); + + const result = await this.deps.taxRegimeFinder.findByCodeInCompany({ + companyId: params.companyId, + code: taxRegimeCode, + transaction: params.transaction, + }); + + if (result.isFailure) { + return Result.fail(result.error); + } + + if (result.data.isNone()) { + return Result.ok( + Maybe.some({ + code: taxRegimeCode, + description: "", + }) + ); + } + + const taxRegime = result.data.unwrap(); + + return Result.ok( + Maybe.some({ + code: taxRegime.code, + description: taxRegime.description, + }) + ); + } +} diff --git a/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/build-proforma-tax-resolver.ts b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/build-proforma-tax-resolver.ts new file mode 100644 index 00000000..21c68bf7 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/build-proforma-tax-resolver.ts @@ -0,0 +1,16 @@ +import type { ITaxDefinitionPublicFinder, ITaxRegimePublicFinder } from "@erp/catalogs/api"; + +import { TaxDefinitionToTaxMapper } from "../../../common/mappers"; + +import { ProformaTaxResolver } from "./proforma-tax-resolver"; + +export function buildProformaTaxResolver(deps: { + taxDefinitionFinder: ITaxDefinitionPublicFinder; + taxRegimeFinder: ITaxRegimePublicFinder; +}): ProformaTaxResolver { + return new ProformaTaxResolver({ + taxDefinitionFinder: deps.taxDefinitionFinder, + taxRegimeFinder: deps.taxRegimeFinder, + taxMapper: new TaxDefinitionToTaxMapper(), + }); +} diff --git a/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/index.ts b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/index.ts new file mode 100644 index 00000000..085f8aa1 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/index.ts @@ -0,0 +1,3 @@ +export * from "./build-proforma-tax-resolver"; +export * from "./proforma-payment-resolver"; +export * from "./proforma-tax-resolver"; diff --git a/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-payment-resolver.ts b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-payment-resolver.ts new file mode 100644 index 00000000..53d8b32b --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-payment-resolver.ts @@ -0,0 +1,62 @@ +import type { IPaymentMethodPublicFinder } from "@erp/catalogs/api"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { type Maybe, Result } from "@repo/rdx-utils"; + +export interface EnsureProformaPaymentMethodByIdParams { + companyId: UniqueID; + id: Maybe; +} + +/** + * Resuelve formas de pago externas al agregado de proforma usando servicios públicos de `catalogs`. + * + * Este servicio pertenece a Application porque coordina dependencias entre módulos + * antes de crear o actualizar una proforma. + * + * No accede a repositorios de `catalogs`, no usa Sequelize y no debe utilizarse + * desde mappers de persistencia. Su responsabilidad es convertir contratos públicos + * de catálogos (`payment-methods`) en Value Objects propios del + * dominio de `customer-invoices`. + * + * Las facturas emitidas no deben depender de este resolver para reconstruirse: + * son documentos históricos y deben contener en persistencia los snapshots fiscales + * necesarios. + */ +export class ProformaPaymentResolver { + public constructor( + private readonly deps: { + paymentMethodFinder: IPaymentMethodPublicFinder; + } + ) {} + + // Comprobar si existe un método de pago (boolean) + + // Recuperar un método de pago si existe (Maybe) + + // Solo asegurarnos que existe ese método de pago + public async ensurePaymentMethodById(params: { + companyId: UniqueID; + id: Maybe; + }): Promise> { + if (params.id.isNone()) { + return Result.ok(undefined); + } + + const id = params.id.unwrap(); + + const result = await this.deps.paymentMethodFinder.findByIdInCompany({ + companyId: params.companyId, + id, + }); + + if (result.isFailure) { + return Result.fail(result.error); + } + + if (result.data.isNone()) { + return Result.fail(new Error(`Payment method not found: ${id.toString()}`)); + } + + return Result.ok(undefined); + } +} diff --git a/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-resolver.ts b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-resolver.ts new file mode 100644 index 00000000..2a10b122 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-resolver.ts @@ -0,0 +1,149 @@ +import type { + ITaxDefinitionPublicFinder, + ITaxRegimePublicFinder, + TaxDefinitionPublicModel, +} from "@erp/catalogs/api"; +import type { Tax } from "@erp/core/api"; +import type { UniqueID, UtcDate } from "@repo/rdx-ddd"; +import { Collection, Maybe, Result } from "@repo/rdx-utils"; + +import { ProformaItemTaxes, type ProformaItemTaxesProps } from "../../../../domain"; +import type { TaxDefinitionToTaxMapper } from "../../../common/mappers"; +import type { ProformaItemTaxCodesInput } from "../../models"; + +export interface EnsureProformaTaxRegimeByCodeParams { + companyId: UniqueID; + code: Maybe; +} + +export interface EnsureProformaItemTaxesParams { + companyId: UniqueID; + atDate: UtcDate; + taxCodes: ProformaItemTaxCodesInput; +} + +/** + * Resuelve datos fiscales externos al agregado de proforma usando servicios públicos de `catalogs`. + * + * Este servicio pertenece a Application porque coordina dependencias entre módulos + * antes de crear o actualizar una proforma. + * + * No accede a repositorios de `catalogs`, no usa Sequelize y no debe utilizarse + * desde mappers de persistencia. Su responsabilidad es convertir contratos públicos + * de catálogos (`tax-definitions` y `tax-regimes`) en Value Objects propios del + * dominio de `customer-invoices`. + * + * Las facturas emitidas no deben depender de este resolver para reconstruirse: + * son documentos históricos y deben contener en persistencia los snapshots fiscales + * necesarios. + */ +export class ProformaTaxResolver { + public constructor( + private readonly deps: { + taxDefinitionFinder: ITaxDefinitionPublicFinder; + taxRegimeFinder: ITaxRegimePublicFinder; + taxMapper: TaxDefinitionToTaxMapper; + } + ) {} + + // Solo asegurarnos que existe un TaxRegime con ese "code" + public async ensureTaxRegimeByCode(params: { + companyId: UniqueID; + code: Maybe; + }): Promise> { + if (params.code.isNone()) { + return Result.ok(undefined); + } + + const code = params.code.unwrap(); + + const result = await this.deps.taxRegimeFinder.findByCodeInCompany({ + companyId: params.companyId, + code, + }); + + if (result.isFailure) { + return Result.fail(result.error); + } + + if (result.data.isNone()) { + return Result.fail(new Error(`Tax regime not found: ${code}`)); + } + + return Result.ok(undefined); + } + + public async ensureItemTaxes( + params: EnsureProformaItemTaxesParams + ): Promise> { + const codes = this.collectCodes(params.taxCodes); + + if (codes.size() === 0) { + return Result.ok(ProformaItemTaxes.empty().getProps()); + } + + const definitions = await this.deps.taxDefinitionFinder.ensureActiveByCodes({ + companyId: params.companyId, + codes, + atDate: params.atDate, + }); + + if (definitions.isFailure) { + return Result.fail(definitions.error); + } + + const iva = this.toMaybeTax(definitions.data, params.taxCodes.ivaCode); + const rec = this.toMaybeTax(definitions.data, params.taxCodes.recCode); + const retention = this.toMaybeTax(definitions.data, params.taxCodes.retentionCode); + + if (iva.isFailure) { + return Result.fail(iva.error); + } + + if (rec.isFailure) { + return Result.fail(rec.error); + } + + if (retention.isFailure) { + return Result.fail(retention.error); + } + + return Result.ok({ + iva: iva.data, + rec: rec.data, + retention: retention.data, + }); + } + + private collectCodes(taxCodes: ProformaItemTaxCodesInput): Collection { + const codes = [taxCodes.ivaCode, taxCodes.recCode, taxCodes.retentionCode] + .filter((code) => code.isSome()) + .map((code) => code.unwrap()); + + return new Collection([...new Set(codes)]); + } + + private toMaybeTax( + taxDefinitions: Collection, + code: Maybe + ): Result, Error> { + if (code.isNone()) { + return Result.ok(Maybe.none()); + } + + const rawCode = code.unwrap(); + const taxDefinition = taxDefinitions.find((item) => item.code === rawCode); + + if (!taxDefinition) { + return Result.fail(new Error(`Tax definition not found: ${rawCode}`)); + } + + const tax = this.deps.taxMapper.toTax(taxDefinition); + + if (tax.isFailure) { + return Result.fail(tax.error); + } + + return Result.ok(Maybe.some(tax.data)); + } +} diff --git a/modules/customer-invoices/src/api/application/proformas/services/index.ts b/modules/customer-invoices/src/api/application/proformas/services/index.ts index 74c53181..fd2211f1 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/index.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/index.ts @@ -1,3 +1,5 @@ +export * from "./assemblers"; +export * from "./catalog-resolver"; export * from "./proforma-creator"; export * from "./proforma-document-generator.interface"; export * from "./proforma-document-metadata-factory"; @@ -6,4 +8,5 @@ export * from "./proforma-finder"; export * from "./proforma-issuer"; export * from "./proforma-number-generator.interface"; export * from "./proforma-public-services.interface"; +export * from "./proforma-status-charger"; export * from "./proforma-updater"; diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts index 702a216e..aa423742 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts @@ -1,42 +1,55 @@ import type { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; -import { type IProformaCreateProps, Proforma } from "../../../domain"; +import { + type IProformaCreateProps, + type IProformaItemCreateProps, + Proforma, +} from "../../../domain"; +import type { ProformaCreateInputProps } from "../models"; import type { IProformaRepository } from "../repositories"; +import type { ProformaPaymentResolver } from "./catalog-resolver/proforma-payment-resolver"; +import type { ProformaTaxResolver } from "./catalog-resolver/proforma-tax-resolver"; import type { IProformaNumberGenerator } from "./proforma-number-generator.interface"; export interface IProformaCreatorParams { companyId: UniqueID; id: UniqueID; - props: Omit; - transaction: unknown; + props: ProformaCreateInputProps; + transaction?: unknown; } export interface IProformaCreator { create(params: IProformaCreatorParams): Promise>; } -type ProformaCreatorDeps = { - numberService: IProformaNumberGenerator; - repository: IProformaRepository; -}; - export class ProformaCreator implements IProformaCreator { - private readonly numberService: IProformaNumberGenerator; - private readonly repository: IProformaRepository; - - constructor(deps: ProformaCreatorDeps) { - this.numberService = deps.numberService; - this.repository = deps.repository; - } + public constructor( + private readonly deps: { + repository: IProformaRepository; + numberService: IProformaNumberGenerator; + taxResolver: ProformaTaxResolver; + paymentResolver: ProformaPaymentResolver; + } + ) {} async create(params: IProformaCreatorParams): Promise> { const { companyId, id, props, transaction } = params; + const resolvedProps = await this.resolveCreateProps(props); + + if (resolvedProps.isFailure) { + return Result.fail(resolvedProps.error); + } + // 1. Obtener siguiente número const { series } = props; - const numberResult = await this.numberService.getNextForCompany(companyId, series, transaction); + const numberResult = await this.deps.numberService.getNextForCompany( + companyId, + series, + transaction + ); if (numberResult.isFailure) { return Result.fail(numberResult.error); @@ -45,7 +58,7 @@ export class ProformaCreator implements IProformaCreator { const invoiceNumber = numberResult.data; // 2. Crear agregado - const proformaResult = Proforma.create({ ...props, invoiceNumber, companyId }, id); + const proformaResult = Proforma.create({ ...resolvedProps.data, invoiceNumber, companyId }, id); if (proformaResult.isFailure) { return Result.fail(proformaResult.error); @@ -54,7 +67,7 @@ export class ProformaCreator implements IProformaCreator { const proforma = proformaResult.data; // 3. Persistir - const saveResult = await this.repository.create(proforma, transaction); + const saveResult = await this.deps.repository.create(proforma, transaction); if (saveResult.isFailure) { return Result.fail(saveResult.error); @@ -62,4 +75,53 @@ export class ProformaCreator implements IProformaCreator { return Result.ok(proforma); } + + private async resolveCreateProps( + props: ProformaCreateInputProps + ): Promise> { + // Tax Regime => comprobar que existe + const taxRegimeResult = await this.deps.taxResolver.ensureTaxRegimeByCode({ + companyId: props.companyId, + code: props.taxRegimeCode, + }); + + if (taxRegimeResult.isFailure) { + return Result.fail(taxRegimeResult.error); + } + + // Payment method => comprobar que existe + const paymentMethodResult = await this.deps.paymentResolver.ensurePaymentMethodById({ + companyId: props.companyId, + id: props.paymentMethodId, + }); + + if (paymentMethodResult.isFailure) { + return Result.fail(paymentMethodResult.error); + } + + // Items + const resolvedItems: IProformaItemCreateProps[] = []; + + for (const item of props.items) { + const taxes = await this.deps.taxResolver.ensureItemTaxes({ + companyId: props.companyId, + atDate: props.invoiceDate, + taxCodes: item.taxes, + }); + + if (taxes.isFailure) { + return Result.fail(taxes.error); + } + + resolvedItems.push({ + ...item, + taxes: taxes.data, + }); + } + + return Result.ok({ + ...props, + items: resolvedItems, + }); + } } diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-public-services.interface.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-public-services.interface.ts index b7911bff..8595720a 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-public-services.interface.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-public-services.interface.ts @@ -27,11 +27,6 @@ export interface IProformaPublicServices { context: IProformaServicesContext ) => Promise>; - getProformaByFactuGESId: ( - factugesId: string, - context: IProformaServicesContext - ) => Promise>; - getProformaSnapshotById: ( id: UniqueID, context: IProformaServicesContext diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-status-charger.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-status-charger.ts new file mode 100644 index 00000000..6eb5d565 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-status-charger.ts @@ -0,0 +1,66 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { Proforma } from "../../../domain"; +import { InvoiceStatus } from "../../../domain"; +import type { IProformaRepository } from "../repositories"; + +export interface IProformaStatusChanger { + changeStatus(params: { + companyId: UniqueID; + id: UniqueID; + newStatus: string; + transaction?: unknown; + }): Promise>; +} + +export class ProformaStatusChanger implements IProformaStatusChanger { + public constructor( + private readonly deps: { + repository: IProformaRepository; + } + ) {} + + public async changeStatus(params: { + companyId: UniqueID; + id: UniqueID; + newStatus: string; + transaction?: unknown; + }): Promise> { + const statusResult = InvoiceStatus.create(params.newStatus); + + if (statusResult.isFailure) { + return Result.fail(statusResult.error); + } + + const proformaResult = await this.deps.repository.getByIdInCompany( + params.companyId, + params.id, + params.transaction + ); + + if (proformaResult.isFailure) { + return Result.fail(proformaResult.error); + } + + const proforma = proformaResult.data; + + const changeResult = proforma.changeStatus(statusResult.data); + + if (changeResult.isFailure) { + return Result.fail(changeResult.error); + } + + if (!changeResult.data) { + return Result.ok(proforma); + } + + const updateResult = await this.deps.repository.update(proforma, params.transaction); + + if (updateResult.isFailure) { + return Result.fail(updateResult.error); + } + + return Result.ok(proforma); + } +} diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts index d4de5bb7..c933c06d 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts @@ -1,39 +1,41 @@ -import type { UniqueID } from "@repo/rdx-ddd"; +import type { UniqueID, UtcDate } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; -import type { Proforma, ProformaPatchProps } from "../../../domain"; +import type { Proforma, ProformaItemPatchProps, ProformaPatchProps } from "../../../domain"; +import type { ProformaPatchInputProps } from "../models"; import type { IProformaRepository } from "../repositories"; +import type { ProformaPaymentResolver } from "./catalog-resolver/proforma-payment-resolver"; +import type { ProformaTaxResolver } from "./catalog-resolver/proforma-tax-resolver"; + export interface IProformaUpdater { update(params: { companyId: UniqueID; id: UniqueID; - patchProps: ProformaPatchProps; + patchProps: ProformaPatchInputProps; transaction: unknown; }): Promise>; } -type ProformaUpdaterDeps = { - repository: IProformaRepository; -}; - export class ProformaUpdater implements IProformaUpdater { - private readonly repository: IProformaRepository; - - constructor(deps: ProformaUpdaterDeps) { - this.repository = deps.repository; - } + public constructor( + private readonly deps: { + repository: IProformaRepository; + taxResolver: ProformaTaxResolver; + paymentResolver: ProformaPaymentResolver; + } + ) {} async update(params: { companyId: UniqueID; id: UniqueID; - patchProps: ProformaPatchProps; + patchProps: ProformaPatchInputProps; transaction: unknown; }): Promise> { const { companyId, id, patchProps, transaction } = params; // Recuperar agregado existente - const existingResult = await this.repository.getByIdInCompany(companyId, id, transaction); + const existingResult = await this.deps.repository.getByIdInCompany(companyId, id, transaction); if (existingResult.isFailure) { return Result.fail(existingResult.error); @@ -41,15 +43,25 @@ export class ProformaUpdater implements IProformaUpdater { const proforma = existingResult.data; + const resolvedPatch = await this.resolvePatchProps({ + companyId, + currentInvoiceDate: proforma.invoiceDate, + patch: patchProps, + }); + + if (resolvedPatch.isFailure) { + return Result.fail(resolvedPatch.error); + } + // Aplicar cambios en el agregado - const updateResult = proforma.update(patchProps); + const updateResult = proforma.update(resolvedPatch.data); if (updateResult.isFailure) { return Result.fail(updateResult.error); } // Persistir cambios - const saveResult = await this.repository.update(proforma, transaction); + const saveResult = await this.deps.repository.update(proforma, transaction); if (saveResult.isFailure) { return Result.fail(saveResult.error); @@ -57,4 +69,63 @@ export class ProformaUpdater implements IProformaUpdater { return Result.ok(proforma); } + + private async resolvePatchProps(params: { + companyId: UniqueID; + currentInvoiceDate: UtcDate; + patch: ProformaPatchInputProps; + }): Promise> { + const { patch, companyId, currentInvoiceDate } = params; + + if (patch.taxRegimeCode !== undefined) { + const taxRegimeResult = await this.deps.taxResolver.ensureTaxRegimeByCode({ + companyId, + code: patch.taxRegimeCode, + }); + + if (taxRegimeResult.isFailure) { + return Result.fail(taxRegimeResult.error); + } + } + + if (patch.paymentMethodId !== undefined) { + const paymentMethodResult = await this.deps.paymentResolver.ensurePaymentMethodById({ + companyId, + id: patch.paymentMethodId, + }); + + if (paymentMethodResult.isFailure) { + return Result.fail(paymentMethodResult.error); + } + } + + if (patch.items === undefined) { + return Result.ok(patch as ProformaPatchProps); + } + + const effectiveInvoiceDate = patch.invoiceDate ?? currentInvoiceDate; + const resolvedItems: ProformaItemPatchProps[] = []; + + for (const item of patch.items) { + const taxes = await this.deps.taxResolver.ensureItemTaxes({ + companyId, + atDate: effectiveInvoiceDate, + taxCodes: item.taxes, + }); + + if (taxes.isFailure) { + return Result.fail(taxes.error); + } + + resolvedItems.push({ + ...item, + taxes: taxes.data, + }); + } + + return Result.ok({ + ...patch, + items: resolvedItems, + }); + } } diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts index 1c50e780..11442c33 100644 --- a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts @@ -2,7 +2,7 @@ import type { ISnapshotBuilder } from "@erp/core/api"; import { maybeToNullable } from "@repo/rdx-ddd"; import type { GetProformaByIdResponseDTO } from "../../../../../common"; -import type { Proforma } from "../../../../domain"; +import type { ProformaFullReadModel } from "../../models"; import type { IProformaItemsFullSnapshotBuilder } from "./proforma-items-full-snapshot-builder"; import type { IProformaRecipientFullSnapshotBuilder } from "./proforma-recipient-full-snapshot-builder"; @@ -11,17 +11,18 @@ import type { IProformaTaxesFullSnapshotBuilder } from "./proforma-taxes-full-sn export type ProformaFullSnapshot = GetProformaByIdResponseDTO; export interface IProformaFullSnapshotBuilder - extends ISnapshotBuilder {} + extends ISnapshotBuilder {} export class ProformaFullSnapshotBuilder implements IProformaFullSnapshotBuilder { constructor( private readonly itemsBuilder: IProformaItemsFullSnapshotBuilder, private readonly recipientBuilder: IProformaRecipientFullSnapshotBuilder, private readonly taxesBuilder: IProformaTaxesFullSnapshotBuilder - //private readonly paymentMethodBuilder: IProformaPaymentMethodFullSnapshotBuilder ) {} - toOutput(proforma: Proforma): ProformaFullSnapshot { + toOutput(source: ProformaFullReadModel): ProformaFullSnapshot { + const { proforma } = source; + const calculationContext = { languageCode: proforma.languageCode, currencyCode: proforma.currencyCode, @@ -31,12 +32,6 @@ export class ProformaFullSnapshotBuilder implements IProformaFullSnapshotBuilder const items = this.itemsBuilder.toOutput(proforma.items, calculationContext); const recipient = this.recipientBuilder.toOutput(proforma); const taxes = this.taxesBuilder.toOutput(proforma.taxes()); - //const paymentMethod = this.paymentMethodBuilder.toOutput(proforma.paymentMethod); - - const paymentMethod = maybeToNullable(proforma.paymentMethodId, (value) => ({ - id: value.toString(), - description: "", - })); const allTotals = proforma.totals(); @@ -63,7 +58,18 @@ export class ProformaFullSnapshotBuilder implements IProformaFullSnapshotBuilder linked_invoice_id: maybeToNullable(proforma.linkedInvoiceId, (value) => value.toString()), - payment_method: paymentMethod, + /*payment_method: { + id: source.paymentMethod., + name: value.name, + description: maybeToNullable(value.description, (description) => description), + }, + + payment_term: null, + + tax_regime: { + code: value.code, + description: maybeToNullable(value.description, (description) => description), + },*/ subtotal_amount: allTotals.subtotalAmount.toObjectString(), items_discount_amount: allTotals.itemsDiscountAmount.toObjectString(), diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/change-status-proforma.use-case.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/change-status-proforma.use-case.ts index 9a66c5ce..ce3ac72d 100644 --- a/modules/customer-invoices/src/api/application/proformas/use-cases/change-status-proforma.use-case.ts +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/change-status-proforma.use-case.ts @@ -3,7 +3,7 @@ import { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import type { ChangeStatusProformaByIdRequestDTO } from "../../../../common"; -import type { IProformaFinder, IProformaUpdater } from "../services"; +import type { IProformaFullReadModelAssembler, IProformaStatusChanger } from "../services"; import type { IProformaFullSnapshotBuilder } from "../snapshot-builders"; type ChangeStatusProformaUseCaseInput = { @@ -12,25 +12,15 @@ type ChangeStatusProformaUseCaseInput = { dto: ChangeStatusProformaByIdRequestDTO; }; -type ChangeStatusProformaUseCaseDeps = { - finder: IProformaFinder; - updater: IProformaUpdater; - fullSnapshotBuilder: IProformaFullSnapshotBuilder; - transactionManager: ITransactionManager; -}; - export class ChangeStatusProformaUseCase { - private readonly finder: IProformaFinder; - private readonly updater: IProformaUpdater; - private readonly fullSnapshotBuilder: IProformaFullSnapshotBuilder; - private readonly transactionManager: ITransactionManager; - - constructor(deps: ChangeStatusProformaUseCaseDeps) { - this.finder = deps.finder; - this.updater = deps.updater; - this.fullSnapshotBuilder = deps.fullSnapshotBuilder; - this.transactionManager = deps.transactionManager; - } + public constructor( + private readonly deps: { + statusChanger: IProformaStatusChanger; + fullReadModelAssembler: IProformaFullReadModelAssembler; + fullSnapshotBuilder: IProformaFullSnapshotBuilder; + transactionManager: ITransactionManager; + } + ) {} public execute(params: ChangeStatusProformaUseCaseInput) { const { @@ -39,40 +29,40 @@ export class ChangeStatusProformaUseCase { dto: { new_status }, } = params; - const proformaIdOrError = UniqueID.create(proforma_id); - if (proformaIdOrError.isFailure) { - return Result.fail(proformaIdOrError.error); + const proformaIdResult = UniqueID.create(proforma_id); + + if (proformaIdResult.isFailure) { + return Result.fail(proformaIdResult.error); } - const proformaId = proformaIdOrError.data; + const proformaId = proformaIdResult.data; - return this.transactionManager.complete(async (transaction) => { + return this.deps.transactionManager.complete(async (transaction) => { try { - /** 1. Recuperamos la proforma */ - const proformaResult = await this.finder.findProformaById( + const changeResult = await this.deps.statusChanger.changeStatus({ companyId, - proformaId, - transaction - ); - if (proformaResult.isFailure) { - return Result.fail(proformaResult.error); + id: proformaId, + newStatus: new_status!, + transaction, + }); + + if (changeResult.isFailure) { + return Result.fail(changeResult.error); } - const proforma = proformaResult.data; - - /** 2. Hacer el cambio de estado */ - const transitionResult = await this.proformaDomainService.transition(proforma, new_status!); - if (transitionResult.isFailure) return Result.fail(transitionResult.error); - - const updateResult = await this.updater.updateProformaStatusByIdInCompany( + const readModelResult = await this.deps.fullReadModelAssembler.assemble({ companyId, - proformaId, - transitionResult.data.status, - transaction - ); - if (updateResult.isFailure) return Result.fail(updateResult.error); + proforma: changeResult.data, + transaction, + }); - return Result.ok(); + if (readModelResult.isFailure) { + return Result.fail(readModelResult.error); + } + + const fullSnapshot = this.deps.fullSnapshotBuilder.toOutput(readModelResult.data); + + return Result.ok(fullSnapshot); } catch (error: unknown) { return Result.fail(error as Error); } diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/create-proforma.use-case.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/create-proforma.use-case.ts new file mode 100644 index 00000000..8a4108e1 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/create-proforma.use-case.ts @@ -0,0 +1,63 @@ +import type { ITransactionManager } from "@erp/core/api"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { CreateProformaRequestDTO } from "../../../../common"; +import type { ICreateProformaInputMapper } from "../mappers"; +import type { IProformaCreator, IProformaFullReadModelAssembler } from "../services"; +import type { IProformaFullSnapshotBuilder } from "../snapshot-builders"; + +type CreateProformaUseCaseInput = { + companyId: UniqueID; + dto: CreateProformaRequestDTO; +}; + +export class CreateProformaUseCase { + public constructor( + private readonly deps: { + dtoMapper: ICreateProformaInputMapper; + creator: IProformaCreator; + fullReadModelAssembler: IProformaFullReadModelAssembler; + fullSnapshotBuilder: IProformaFullSnapshotBuilder; + transactionManager: ITransactionManager; + } + ) {} + + public async execute(params: CreateProformaUseCaseInput) { + const { dto, companyId } = params; + + // 1) Mapear DTO → props de dominio + const mappedPropsResult = this.deps.dtoMapper.map(dto, { companyId }); + if (mappedPropsResult.isFailure) { + return Result.fail(mappedPropsResult.error); + } + + const { props, id } = mappedPropsResult.data; + + return this.deps.transactionManager.complete(async (transaction: unknown) => { + try { + const createResult = await this.deps.creator.create({ companyId, id, props, transaction }); + + if (createResult.isFailure) { + return Result.fail(createResult.error); + } + + const readModelResult = await this.deps.fullReadModelAssembler.assemble({ + companyId, + proforma: createResult.data, + transaction, + }); + + if (readModelResult.isFailure) { + return Result.fail(readModelResult.error); + } + + const snapshot = this.deps.fullSnapshotBuilder.toOutput(readModelResult.data); + + return Result.ok(snapshot); + } catch (error: unknown) { + return Result.fail(error as Error); + } + }); + } +} diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/create-proforma/create-proforma.use-case.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/create-proforma/create-proforma.use-case.ts deleted file mode 100644 index f24478f8..00000000 --- a/modules/customer-invoices/src/api/application/proformas/use-cases/create-proforma/create-proforma.use-case.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { ITransactionManager } from "@erp/core/api"; -import type { UniqueID } from "@repo/rdx-ddd"; -import { Result } from "@repo/rdx-utils"; - -import type { CreateProformaRequestDTO } from "../../../../../common"; -import type { ICreateProformaInputMapper } from "../../mappers"; -import type { IProformaCreator } from "../../services"; -import type { IProformaFullSnapshotBuilder } from "../../snapshot-builders"; - -type CreateProformaUseCaseInput = { - companyId: UniqueID; - dto: CreateProformaRequestDTO; -}; - -type CreateProformaUseCaseDeps = { - dtoMapper: ICreateProformaInputMapper; - creator: IProformaCreator; - fullSnapshotBuilder: IProformaFullSnapshotBuilder; - transactionManager: ITransactionManager; -}; - -export class CreateProformaUseCase { - private readonly dtoMapper: ICreateProformaInputMapper; - private readonly creator: IProformaCreator; - private readonly fullSnapshotBuilder: IProformaFullSnapshotBuilder; - private readonly transactionManager: ITransactionManager; - - constructor(deps: CreateProformaUseCaseDeps) { - this.dtoMapper = deps.dtoMapper; - this.creator = deps.creator; - this.fullSnapshotBuilder = deps.fullSnapshotBuilder; - this.transactionManager = deps.transactionManager; - } - - public async execute(params: CreateProformaUseCaseInput) { - const { dto, companyId } = params; - - // 1) Mapear DTO → props de dominio - const mappedPropsResult = this.dtoMapper.map(dto, { companyId }); - if (mappedPropsResult.isFailure) { - return Result.fail(mappedPropsResult.error); - } - - const { props, id } = mappedPropsResult.data; - - return this.transactionManager.complete(async (transaction: unknown) => { - try { - const createResult = await this.creator.create({ companyId, id, props, transaction }); - - if (createResult.isFailure) { - return Result.fail(createResult.error); - } - - const snapshot = this.fullSnapshotBuilder.toOutput(createResult.data); - - return Result.ok(snapshot); - } catch (error: unknown) { - return Result.fail(error as Error); - } - }); - } -} diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/create-proforma/index.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/create-proforma/index.ts deleted file mode 100644 index d1d4cd6d..00000000 --- a/modules/customer-invoices/src/api/application/proformas/use-cases/create-proforma/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./create-proforma.use-case"; diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/get-proforma-by-id.use-case.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/get-proforma-by-id.use-case.ts index babd3659..f56f5a94 100644 --- a/modules/customer-invoices/src/api/application/proformas/use-cases/get-proforma-by-id.use-case.ts +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/get-proforma-by-id.use-case.ts @@ -2,7 +2,7 @@ import type { ITransactionManager } from "@erp/core/api"; import { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; -import type { IProformaFinder } from "../services"; +import type { IProformaFinder, IProformaFullReadModelAssembler } from "../services"; import type { IProformaFullSnapshotBuilder } from "../snapshot-builders"; type GetProformaUseCaseInput = { @@ -11,34 +11,49 @@ type GetProformaUseCaseInput = { }; export class GetProformaByIdUseCase { - constructor( - private readonly finder: IProformaFinder, - private readonly fullSnapshotBuilder: IProformaFullSnapshotBuilder, - private readonly transactionManager: ITransactionManager + public constructor( + private readonly deps: { + finder: IProformaFinder; + fullReadModelAssembler: IProformaFullReadModelAssembler; + fullSnapshotBuilder: IProformaFullSnapshotBuilder; + transactionManager: ITransactionManager; + } ) {} public execute(params: GetProformaUseCaseInput) { const { proforma_id, companyId } = params; const idOrError = UniqueID.create(proforma_id); + if (idOrError.isFailure) { return Result.fail(idOrError.error); } const proformaId = idOrError.data; - return this.transactionManager.complete(async (transaction) => { + return this.deps.transactionManager.complete(async (transaction) => { try { - const proformaResult = await this.finder.findProformaById( + const proformaResult = await this.deps.finder.findProformaById( companyId, proformaId, transaction ); + if (proformaResult.isFailure) { return Result.fail(proformaResult.error); } - const fullSnapshot = this.fullSnapshotBuilder.toOutput(proformaResult.data); + const readModelResult = await this.deps.fullReadModelAssembler.assemble({ + companyId, + proforma: proformaResult.data, + transaction, + }); + + if (readModelResult.isFailure) { + return Result.fail(readModelResult.error); + } + + const fullSnapshot = this.deps.fullSnapshotBuilder.toOutput(readModelResult.data); return Result.ok(fullSnapshot); } catch (error: unknown) { diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/index.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/index.ts index 3f04906f..e23936a7 100644 --- a/modules/customer-invoices/src/api/application/proformas/use-cases/index.ts +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/index.ts @@ -1,5 +1,5 @@ export * from "./change-status-proforma.use-case"; -export * from "./create-proforma"; +export * from "./create-proforma.use-case"; //export * from "./delete-proforma.use-case"; export * from "./get-proforma-by-id.use-case"; export * from "./issue-proforma.use-case"; diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.ts index b2997aa3..10d24f0e 100644 --- a/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.ts +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.ts @@ -10,12 +10,6 @@ type IssueProformaUseCaseInput = { proforma_id: string; }; -type IssueProformaUseCaseDeps = { - issuedInvoiceServices: IIssuedInvoicePublicServices; - finder: IProformaFinder; - issuer: IProformaIssuer; - transactionManager: ITransactionManager; -}; /** * Caso de uso: Conversión de una issuedinvoice a factura definitiva. * @@ -26,17 +20,14 @@ type IssueProformaUseCaseDeps = { * - Persiste ambas dentro de la misma transacción */ export class IssueProformaUseCase { - private readonly issuedInvoiceServices: IIssuedInvoicePublicServices; - private readonly finder: IProformaFinder; - private readonly issuer: IProformaIssuer; - private readonly transactionManager: ITransactionManager; - - constructor(deps: IssueProformaUseCaseDeps) { - this.issuedInvoiceServices = deps.issuedInvoiceServices; - this.finder = deps.finder; - this.issuer = deps.issuer; - this.transactionManager = deps.transactionManager; - } + public constructor( + private readonly deps: { + issuedInvoiceServices: IIssuedInvoicePublicServices; + finder: IProformaFinder; + issuer: IProformaIssuer; + transactionManager: ITransactionManager; + } + ) {} public execute(params: IssueProformaUseCaseInput) { const { proforma_id, companyId } = params; @@ -46,10 +37,10 @@ export class IssueProformaUseCase { const proformaId = proformaIdOrError.data; - return this.transactionManager.complete(async (transaction) => { + return this.deps.transactionManager.complete(async (transaction) => { try { // 1. Recuperamos la proforma - const proformaResult = await this.finder.findProformaById( + const proformaResult = await this.deps.finder.findProformaById( companyId, proformaId, transaction @@ -60,7 +51,7 @@ export class IssueProformaUseCase { // 2. Generamos la factura definitiva y la guardamos const issuedInvoiceId = UniqueID.generateNewID(); - const createPropsOrError = await this.issuer.issueProforma({ + const createPropsOrError = await this.deps.issuer.issueProforma({ companyId, issuedInvoiceId, proforma, @@ -74,7 +65,7 @@ export class IssueProformaUseCase { const createProps = createPropsOrError.data; // Creamos y guardamos en persistencia la factura definitiva - const invoiceResult = await this.issuedInvoiceServices.createIssuedInvoice( + const invoiceResult = await this.deps.issuedInvoiceServices.createIssuedInvoice( issuedInvoiceId, createProps, { diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/update-proforma-by-id.use-case.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/update-proforma-by-id.use-case.ts index c11eec39..22bd537a 100644 --- a/modules/customer-invoices/src/api/application/proformas/use-cases/update-proforma-by-id.use-case.ts +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/update-proforma-by-id.use-case.ts @@ -3,9 +3,8 @@ import { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import type { UpdateProformaByIdRequestDTO } from "../../../../common"; -import type { ProformaPatchProps } from "../../../domain"; import type { IUpdateProformaInputMapper } from "../mappers"; -import type { IProformaUpdater } from "../services"; +import type { IProformaFullReadModelAssembler, IProformaUpdater } from "../services"; import type { IProformaFullSnapshotBuilder } from "../snapshot-builders"; type UpdateProformaUseCaseInput = { @@ -14,25 +13,16 @@ type UpdateProformaUseCaseInput = { dto: UpdateProformaByIdRequestDTO; }; -type UpdateProformaUseCaseDeps = { - dtoMapper: IUpdateProformaInputMapper; - updater: IProformaUpdater; - fullSnapshotBuilder: IProformaFullSnapshotBuilder; - transactionManager: ITransactionManager; -}; - export class UpdateProformaByIdUseCase { - private readonly dtoMapper: IUpdateProformaInputMapper; - private readonly updater: IProformaUpdater; - private readonly fullSnapshotBuilder: IProformaFullSnapshotBuilder; - private readonly transactionManager: ITransactionManager; - - constructor(deps: UpdateProformaUseCaseDeps) { - this.dtoMapper = deps.dtoMapper; - this.updater = deps.updater; - this.fullSnapshotBuilder = deps.fullSnapshotBuilder; - this.transactionManager = deps.transactionManager; - } + public constructor( + private readonly deps: { + dtoMapper: IUpdateProformaInputMapper; + updater: IProformaUpdater; + fullReadModelAssembler: IProformaFullReadModelAssembler; + fullSnapshotBuilder: IProformaFullSnapshotBuilder; + transactionManager: ITransactionManager; + } + ) {} public execute(params: UpdateProformaUseCaseInput) { const { companyId, proforma_id, dto } = params; @@ -45,16 +35,16 @@ export class UpdateProformaByIdUseCase { const proformaId = proformaIdOrError.data; // Mapear DTO → props de dominio - const patchPropsResult = this.dtoMapper.map(dto, { companyId }); + const patchPropsResult = this.deps.dtoMapper.map(dto, { companyId }); if (patchPropsResult.isFailure) { return patchPropsResult; } - const patchProps: ProformaPatchProps = patchPropsResult.data; + const patchProps = patchPropsResult.data; - return this.transactionManager.complete(async (transaction) => { + return this.deps.transactionManager.complete(async (transaction) => { try { - const updateResult = await this.updater.update({ + const updateResult = await this.deps.updater.update({ companyId, id: proformaId, patchProps, @@ -65,57 +55,20 @@ export class UpdateProformaByIdUseCase { return Result.fail(updateResult.error); } - return Result.ok(this.fullSnapshotBuilder.toOutput(updateResult.data)); - } catch (error: unknown) { - return Result.fail(error as Error); - } - }); - } -} - -/* - - const presenter = this.presenterRegistry.getPresenter({ - resource: "proforma", - projection: "FULL", - }) as ProformaFullPresenter; - - // Mapear DTO → props de dominio - const patchPropsResult = mapDTOToUpdateProformaInvoicePatchProps(dto); - if (patchPropsResult.isFailure) { - return Result.fail(patchPropsResult.error); - } - - const patchProps: ProformaPatchProps = patchPropsResult.data; - - return this.transactionManager.complete(async (transaction: unknown) => { - try { - const updatedInvoice = await this.service.patchProformaByIdInCompany( + const readModelResult = await this.deps.fullReadModelAssembler.assemble({ companyId, - invoiceId, - patchProps, - transaction - ); + proforma: updateResult.data, + transaction, + }); - if (updatedInvoice.isFailure) { - return Result.fail(updatedInvoice.error); + if (readModelResult.isFailure) { + return Result.fail(readModelResult.error); } - const invoiceOrError = await this.service.updateProformaInCompany( - companyId, - updatedInvoice.data, - transaction - ); - if (invoiceOrError.isFailure) return Result.fail(invoiceOrError.error); - - const invoice = invoiceOrError.data; - const dto = presenter.toOutput(invoice); - return Result.ok(dto); + return Result.ok(this.deps.fullSnapshotBuilder.toOutput(readModelResult.data)); } catch (error: unknown) { return Result.fail(error as Error); } }); } } - -*/ diff --git a/modules/customer-invoices/src/api/domain/common/entities/index.ts b/modules/customer-invoices/src/api/domain/common/entities/index.ts index 7556a841..66fa2ce8 100644 --- a/modules/customer-invoices/src/api/domain/common/entities/index.ts +++ b/modules/customer-invoices/src/api/domain/common/entities/index.ts @@ -1 +1,2 @@ -export * from "./invoice-payment-method"; +export * from "./invoice-payment-method.entity"; +export * from "./invoice-tax-regime.entity"; diff --git a/modules/customer-invoices/src/api/domain/common/entities/invoice-payment-method/invoice-payment-method.ts b/modules/customer-invoices/src/api/domain/common/entities/invoice-payment-method.entity.ts similarity index 77% rename from modules/customer-invoices/src/api/domain/common/entities/invoice-payment-method/invoice-payment-method.ts rename to modules/customer-invoices/src/api/domain/common/entities/invoice-payment-method.entity.ts index 5c6507ca..9f24f2b5 100644 --- a/modules/customer-invoices/src/api/domain/common/entities/invoice-payment-method/invoice-payment-method.ts +++ b/modules/customer-invoices/src/api/domain/common/entities/invoice-payment-method.entity.ts @@ -2,7 +2,7 @@ import { DomainEntity, type UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; export interface InvoicePaymentMethodProps { - paymentDescription: string; + name: string; } export class InvoicePaymentMethod extends DomainEntity { @@ -15,8 +15,8 @@ export class InvoicePaymentMethod extends DomainEntity { + private static readonly ERROR_CODE = "INVALID_INVOICE_TAX_REGIME"; + + private static readonly MIN_CODE = 1; + private static readonly MAX_CODE = 99; + + protected static validate(props: InvoiceTaxRegimeProps) { + const schema = z.object({ + code: z + .string() + .trim() + .regex(/^\d{2}$/, { + message: "Code must be a two-digit numeric string", + }) + .refine( + (value) => { + const numericValue = Number(value); + + return ( + numericValue >= InvoiceTaxRegime.MIN_CODE && numericValue <= InvoiceTaxRegime.MAX_CODE + ); + }, + { + message: "Code must be between 01 and 99", + } + ), + + description: z.string().trim().min(1, { + message: "Description is required", + }), + }); + + return schema.safeParse(props); + } + + static create(props: InvoiceTaxRegimeProps, id?: UniqueID): Result { + const propsAreValid = InvoiceTaxRegime.validate(props); + + if (!propsAreValid.success) { + const detail = propsAreValid.error.message; + + return Result.fail( + new DomainValidationError(InvoiceTaxRegime.ERROR_CODE, "tax-regime", detail) + ); + } + + return Result.ok( + new InvoiceTaxRegime( + { + code: props.code.trim(), + description: props.description.trim(), + }, + id + ) + ); + } + + getProps(): InvoiceTaxRegimeProps { + return { + code: this.props.code, + description: this.props.description, + }; + } + + get code(): string { + return this.props.code; + } + + get description(): string { + return this.props.description; + } + + toObjectString() { + return { + id: String(this.id), + code: String(this.code), + description: String(this.description), + }; + } +} diff --git a/modules/customer-invoices/src/api/domain/common/value-objects/index.ts b/modules/customer-invoices/src/api/domain/common/value-objects/index.ts index 0a6646c4..d69995a8 100644 --- a/modules/customer-invoices/src/api/domain/common/value-objects/index.ts +++ b/modules/customer-invoices/src/api/domain/common/value-objects/index.ts @@ -1,7 +1,9 @@ +export * from "../entities/invoice-tax-regime.entity"; + export * from "./invoice-address-type.vo"; export * from "./invoice-amount.vo"; export * from "./invoice-number.vo"; -export * from "./invoice-recipient"; +export * from "./invoice-recipient/invoice-recipient.vo"; export * from "./invoice-serie.vo"; export * from "./invoice-status.vo"; export * from "./item-amount.vo"; diff --git a/modules/customer-invoices/src/api/domain/common/value-objects/invoice-status.vo.ts b/modules/customer-invoices/src/api/domain/common/value-objects/invoice-status.vo.ts index 40da6fba..2285db8c 100644 --- a/modules/customer-invoices/src/api/domain/common/value-objects/invoice-status.vo.ts +++ b/modules/customer-invoices/src/api/domain/common/value-objects/invoice-status.vo.ts @@ -86,10 +86,18 @@ export class InvoiceStatus extends ValueObject { return this.getProps(); } - canTransitionTo(nextStatus: string): boolean { + public canTransitionTo(nextStatus: InvoiceStatus): boolean { + return INVOICE_TRANSITIONS[this.props.value].includes(nextStatus.toPrimitive()); + } + + public canTransitionToValue(nextStatus: string): boolean { return INVOICE_TRANSITIONS[this.props.value].includes(nextStatus); } + public isIssued(): boolean { + return this.props.value === INVOICE_STATUS.ISSUED; + } + toString() { return String(this.props.value); } diff --git a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts index 481f9110..34624e0d 100644 --- a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts +++ b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts @@ -12,6 +12,7 @@ import { import { type Collection, type Maybe, Result } from "@repo/rdx-utils"; import { + INVOICE_STATUS, InvoiceAmount, type InvoiceNumber, type InvoiceRecipient, @@ -26,7 +27,7 @@ import { type ProformaItemPatchProps, ProformaItems, } from "../entities"; -import { ProformaItemMismatch } from "../errors"; +import { InvalidProformaTransitionError, ProformaItemMismatch } from "../errors"; import type { IProformaTaxTotals, ProformaCalculationContext } from "../services"; import { ProformaItemTaxes } from "../value-objects"; @@ -300,6 +301,38 @@ export class Proforma extends AggregateRoot implements IP return this.taxRegimeCode.isSome(); } + public changeStatus(nextStatus: InvoiceStatus): Result { + const currentStatus = this.status; + + if (currentStatus.toPrimitive() === nextStatus.toPrimitive()) { + return Result.ok(false); + } + + if (nextStatus.toPrimitive() === INVOICE_STATUS.ISSUED) { + return Result.fail( + new InvalidProformaTransitionError( + currentStatus.toPrimitive(), + nextStatus.toPrimitive(), + this.id.toString() + ) + ); + } + + if (!currentStatus.canTransitionTo(nextStatus)) { + return Result.fail( + new InvalidProformaTransitionError( + currentStatus.toPrimitive(), + nextStatus.toPrimitive(), + this.id.toString() + ) + ); + } + + this.props.status = nextStatus; + + return Result.ok(true); + } + public issue(): Result { // Antes de cambiar el estado de la proforma, // comprobamos que se cumplen las condiciones @@ -376,6 +409,16 @@ export class Proforma extends AggregateRoot implements IP ); } + if (this.taxRegimeCode.isNone()) { + return Result.fail( + new DomainValidationError( + "MISSING_TAX_REGIME", + "taxRegime", + "Tax regime is required to issue the proforma" + ) + ); + } + /*if (this.operationDate.isSome() && this.operationDate.unwrap() > new Date()) { return Result.fail( new DomainValidationError( diff --git a/modules/customer-invoices/src/api/index.ts b/modules/customer-invoices/src/api/index.ts index ffeb92b1..ea98e5d1 100644 --- a/modules/customer-invoices/src/api/index.ts +++ b/modules/customer-invoices/src/api/index.ts @@ -15,7 +15,7 @@ export type { IProformaPublicServices } from "./application"; export const customerInvoicesAPIModule: IModuleServer = { name: "customer-invoices", version: "1.0.0", - dependencies: ["customers"], + dependencies: ["catalogs", "customers"], /** * Fase de SETUP diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-domain.mapper.ts index 0fad2aae..5c930240 100644 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-domain.mapper.ts @@ -142,7 +142,7 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper< const paymentVO = extractOrPushError( InvoicePaymentMethod.create( - { paymentDescription: String(raw.payment_method_description ?? "") }, + { name: String(raw.payment_method_description ?? "") }, paymentId ?? undefined ), "payment_method_description", @@ -482,7 +482,7 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper< notes: maybeToNullable(source.notes, (v) => v.toPrimitive()), payment_method_id: source.paymentMethod.toObjectString().id, - payment_method_description: source.paymentMethod.toObjectString().payment_description, + payment_method_description: source.paymentMethod.toObjectString().name, subtotal_amount_value: source.subtotalAmount.value, subtotal_amount_scale: source.subtotalAmount.scale, diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-persistence-mappers.di.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-persistence-mappers.di.ts index befe225f..52f37f62 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-persistence-mappers.di.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-persistence-mappers.di.ts @@ -1,34 +1,17 @@ -import type { ICatalogs } from "@erp/core/api"; - -import { CreateProformaInputMapper } from "../../../application"; import { SequelizeProformaDomainMapper, SequelizeProformaSummaryMapper } from "../persistence"; export interface IProformaPersistenceMappers { domainMapper: SequelizeProformaDomainMapper; listMapper: SequelizeProformaSummaryMapper; - - createMapper: CreateProformaInputMapper; } -export const buildProformaPersistenceMappers = ( - catalogs: ICatalogs -): IProformaPersistenceMappers => { - const { taxCatalog, paymentCatalog } = catalogs; - +export const buildProformaPersistenceMappers = (): IProformaPersistenceMappers => { // Mappers para el repositorio - const domainMapper = new SequelizeProformaDomainMapper({ - taxCatalog, - paymentCatalog, - }); + const domainMapper = new SequelizeProformaDomainMapper(); const listMapper = new SequelizeProformaSummaryMapper(); - // Mappers el DTO a las props validadas (CustomerProps) y luego construir agregado - const createMapper = new CreateProformaInputMapper(catalogs); - return { domainMapper, listMapper, - - createMapper, }; }; diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts index 1cc67a37..e29953bb 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts @@ -1,4 +1,4 @@ -import { type SetupParams, buildCatalogs } from "@erp/core/api"; +import type { SetupParams } from "@erp/core/api"; import type { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import type { Transaction } from "sequelize"; @@ -6,12 +6,15 @@ import type { Transaction } from "sequelize"; import { type IProformaCreatorParams, type ProformaFullSnapshot, + buildProformaCatalogResolvers, buildProformaCreator, buildProformaFinder, + buildProformaReadModelAssemblers, buildProformaSnapshotBuilders, } from "../../../application"; import type { Proforma } from "../../../domain"; +import { resolveProformaCatalogsDeps } from "./proforma-catalog-deps.di"; import { buildProformaNumberGenerator } from "./proforma-number-generator.di"; import { buildProformaPersistenceMappers } from "./proforma-persistence-mappers.di"; import { buildProformaRepository } from "./proforma-repositories.di"; @@ -28,11 +31,6 @@ export type ProformaPublicServices = { context: ProformaServicesContext ) => Promise>; - getProformaByFactuGESId: ( - factugesId: string, - context: ProformaServicesContext - ) => Promise>; - getProformaSnapshotById: ( id: UniqueID, context: ProformaServicesContext @@ -47,31 +45,56 @@ export type ProformaPublicServices = { export function buildProformaPublicServices( params: SetupParams, - deps: ProformasInternalDeps + _deps: ProformasInternalDeps ): ProformaPublicServices { const { database } = params; - // Infrastructure - const catalogs = buildCatalogs(); - const persistenceMappers = buildProformaPersistenceMappers(catalogs); + const catalogs = resolveProformaCatalogsDeps(params); + + const persistenceMappers = buildProformaPersistenceMappers(); + + const repository = buildProformaRepository({ + database, + mappers: persistenceMappers, + }); - const repository = buildProformaRepository({ database, mappers: persistenceMappers }); const numberService = buildProformaNumberGenerator(); - const snapshotsBuilder = buildProformaSnapshotBuilders(); - - // Application helpers - const creator = buildProformaCreator({ numberService, repository }); const finder = buildProformaFinder(repository); + const catalogResolvers = buildProformaCatalogResolvers({ + taxDefinitionFinder: catalogs.taxDefinition.finder, + taxRegimeFinder: catalogs.taxRegime.finder, + paymentMethodFinder: catalogs.paymentMethod.finder, + }); + + const readModelAssemblers = buildProformaReadModelAssemblers({ + paymentMethodFinder: catalogs.paymentMethod.finder, + taxRegimeFinder: catalogs.taxRegime.finder, + }); + + const snapshotBuilders = buildProformaSnapshotBuilders(); + + const creator = buildProformaCreator({ + repository, + numberService, + taxResolver: catalogResolvers.taxResolver, + paymentResolver: catalogResolvers.paymentResolver, + }); + return { - createProforma: async ( + async createProforma( id: UniqueID, props: IProformaCreatorParams["props"], context: ProformaServicesContext - ) => { + ): Promise> { const { transaction, companyId } = context; - const createResult = await creator.create({ companyId, id, props, transaction }); + const createResult = await creator.create({ + companyId, + id, + props, + transaction, + }); if (createResult.isFailure) { return Result.fail(createResult.error); @@ -80,10 +103,12 @@ export function buildProformaPublicServices( return Result.ok(createResult.data); }, - //internal.useCases.listProformas().execute(filters, context), - - getProformaById: async (id: UniqueID, context: ProformaServicesContext) => { + async getProformaById( + id: UniqueID, + context: ProformaServicesContext + ): Promise> { const { transaction, companyId } = context; + const proformaResult = await finder.findProformaById(companyId, id, transaction); if (proformaResult.isFailure) { @@ -93,36 +118,31 @@ export function buildProformaPublicServices( return Result.ok(proformaResult.data); }, - getProformaByFactuGESId: async (factugesId: string, context: ProformaServicesContext) => { + async getProformaSnapshotById( + id: UniqueID, + context: ProformaServicesContext + ): Promise> { const { transaction, companyId } = context; - const proformaResult = await finder.findProformaByFactuGESId( + + const proformaResult = await finder.findProformaById(companyId, id, transaction); + + if (proformaResult.isFailure) { + return Result.fail(proformaResult.error); + } + + const readModelResult = await readModelAssemblers.full.assemble({ companyId, - factugesId, - transaction - ); + proforma: proformaResult.data, + transaction, + }); - if (proformaResult.isFailure) { - console.error("Error fetching proforma by FactuGES ID:", proformaResult.error); - return Result.fail(proformaResult.error); + if (readModelResult.isFailure) { + return Result.fail(readModelResult.error); } - return Result.ok(proformaResult.data); - }, - - getProformaSnapshotById: async (id: UniqueID, context: ProformaServicesContext) => { - const { transaction, companyId } = context; - const proformaResult = await finder.findProformaById(companyId, id, transaction); - - if (proformaResult.isFailure) { - return Result.fail(proformaResult.error); - } - - const fullSnapshot = snapshotsBuilder.full.toOutput(proformaResult.data); + const fullSnapshot = snapshotBuilders.full.toOutput(readModelResult.data); return Result.ok(fullSnapshot); }, - - //generateProformaReport: (id, options, context) => null, - //internal.useCases.reportProforma().execute(id, options, context), }; } 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 b93aefe1..059f993f 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 @@ -1,4 +1,3 @@ -import type { ICatalogPublicServices } from "@erp/catalogs/api"; import { type ModuleParams, buildTransactionManager } from "@erp/core/api"; import { @@ -15,11 +14,14 @@ import { buildGetProformaByIdUseCase, buildIssueProformaUseCase, buildListProformasUseCase, + buildProformaCatalogResolvers, buildProformaCreator, buildProformaFinder, buildProformaInputMappers, buildProformaIssuer, + buildProformaReadModelAssemblers, buildProformaSnapshotBuilders, + buildProformaStatusChanger, buildProformaToIssuedInvoicePropsConverter, buildProformaUpdater, buildReportProformaUseCase, @@ -30,6 +32,7 @@ import { buildProformaDocumentService } from "./proforma-documents.di"; import { buildProformaNumberGenerator } from "./proforma-number-generator.di"; import { buildProformaPersistenceMappers } from "./proforma-persistence-mappers.di"; import { buildProformaRepository } from "./proforma-repositories.di"; +import { resolveProformaCatalogsDeps } from "./proforrma-catalog-deps.di"; export type ProformasInternalDeps = { useCases: { @@ -51,33 +54,61 @@ export type ProformasInternalDeps = { }; export function buildProformasDependencies(params: ModuleParams): ProformasInternalDeps { - const { database, getService } = params; - const catalogs = getService("catalogs"); + const { database } = params; + + const catalogs = resolveProformaCatalogsDeps(params); - // Infrastructure const transactionManager = buildTransactionManager(database); - const persistenceMappers = buildProformaPersistenceMappers(catalogs); + const persistenceMappers = buildProformaPersistenceMappers(); + + const repository = buildProformaRepository({ + database, + mappers: persistenceMappers, + }); + + const inputMappers = buildProformaInputMappers(); + const snapshotBuilders = buildProformaSnapshotBuilders(); + + const catalogResolvers = buildProformaCatalogResolvers({ + taxDefinitionFinder: catalogs.taxDefinition.finder, + taxRegimeFinder: catalogs.taxRegime.finder, + paymentMethodFinder: catalogs.paymentMethod.finder, + }); + + const readModelAssemblers = buildProformaReadModelAssemblers({ + paymentMethodFinder: catalogs.paymentMethod.finder, + taxRegimeFinder: catalogs.taxRegime.finder, + }); - const repository = buildProformaRepository({ database, mappers: persistenceMappers }); const proformaNumberService = buildProformaNumberGenerator(); - - // Application helpers - const inputMappers = buildProformaInputMappers(catalogs); const finder = buildProformaFinder(repository); - const creator = buildProformaCreator({ numberService: proformaNumberService, repository }); - const proformaToIssuedInvoiceConverter = buildProformaToIssuedInvoicePropsConverter(catalogs); + + const creator = buildProformaCreator({ + taxResolver: catalogResolvers.taxResolver, + paymentResolver: catalogResolvers.paymentResolver, + numberService: proformaNumberService, + repository, + }); + + const updater = buildProformaUpdater({ + repository, + taxResolver: catalogResolvers.taxResolver, + paymentResolver: catalogResolvers.paymentResolver, + }); + + const statusChanger = buildProformaStatusChanger({ + repository, + }); + + const proformaToIssuedInvoiceConverter = buildProformaToIssuedInvoicePropsConverter(); const issuer = buildProformaIssuer({ proformaConverter: proformaToIssuedInvoiceConverter, repository, }); - const updater = buildProformaUpdater({ repository }); - - const snapshotBuilders = buildProformaSnapshotBuilders(); const documentGeneratorPipeline = buildProformaDocumentService(params); - // Internal use cases (factories) return { useCases: { listProformas: () => @@ -90,23 +121,16 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter getProformaById: () => buildGetProformaByIdUseCase({ finder, + fullReadModelAssembler: readModelAssemblers.full, fullSnapshotBuilder: snapshotBuilders.full, transactionManager, }), - reportProforma: () => - buildReportProformaUseCase({ - finder, - fullSnapshotBuilder: snapshotBuilders.full, - reportSnapshotBuilder: snapshotBuilders.report, - documentService: documentGeneratorPipeline, - transactionManager, - }), - createProforma: () => buildCreateProformaUseCase({ creator, dtoMapper: inputMappers.createInputMapper, + fullReadModelAssembler: readModelAssemblers.full, fullSnapshotBuilder: snapshotBuilders.full, transactionManager, }), @@ -115,10 +139,21 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter buildUpdateProformaUseCase({ updater, dtoMapper: inputMappers.updateInputMapper, + fullReadModelAssembler: readModelAssemblers.full, fullSnapshotBuilder: snapshotBuilders.full, transactionManager, }), + reportProforma: () => + buildReportProformaUseCase({ + finder, + fullReadModelAssembler: readModelAssemblers.full, + fullSnapshotBuilder: snapshotBuilders.full, + reportSnapshotBuilder: snapshotBuilders.report, + documentService: documentGeneratorPipeline, + transactionManager, + }), + issueProforma: (publicServices: { issuedInvoiceServices: IIssuedInvoicePublicServices }) => buildIssueProformaUseCase({ publicServices, @@ -129,8 +164,8 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter changeStatusProforma: () => buildChangeStatusProformaUseCase({ - finder, - updater, + statusChanger, + fullReadModelAssembler: readModelAssemblers.full, fullSnapshotBuilder: snapshotBuilders.full, transactionManager, }), diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proforrma-catalog-deps.di.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proforrma-catalog-deps.di.ts new file mode 100644 index 00000000..6ed7825b --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proforrma-catalog-deps.di.ts @@ -0,0 +1,49 @@ +import type { CatalogsPublicServicesType } from "@erp/catalogs/api"; +import type { ModuleParams } from "@erp/core/api"; + +type ProformaCatalogsDeps = { + taxDefinition: { + finder: CatalogsPublicServicesType["taxDefinitions"]["finder"]; + }; + taxRegime: { + finder: CatalogsPublicServicesType["taxRegimes"]["finder"]; + }; + paymentMethod: { + finder: CatalogsPublicServicesType["paymentMethods"]["finder"]; + }; +}; + +export function resolveProformaCatalogsDeps(params: ModuleParams): ProformaCatalogsDeps { + const taxDefinition = + params.getService("catalogs:taxDefinition"); + + if (!taxDefinition?.finder) { + throw new Error("Missing public service: catalogs:taxDefinition.finder"); + } + + const taxRegime = + params.getService("catalogs:taxRegime"); + + if (!taxRegime?.finder) { + throw new Error("Missing public service: catalogs:taxRegime.finder"); + } + + const paymentMethod = + params.getService("catalogs:paymentMethod"); + + if (!paymentMethod?.finder) { + throw new Error("Missing public service: catalogs:paymentMethod.finder"); + } + + return { + taxDefinition: { + finder: taxDefinition.finder, + }, + taxRegime: { + finder: taxRegime.finder, + }, + paymentMethod: { + finder: paymentMethod.finder, + }, + }; +} diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts index 5e4e164d..d9a305d5 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts @@ -1,5 +1,4 @@ import { mockUser, requireAuthenticated, requireCompanyContext } from "@erp/auth/api"; -import type { ICatalogPublicServices } from "@erp/catalogs/api"; import { type RequestWithAuth, type StartParams, validateRequest } from "@erp/core/api"; import { type NextFunction, type Request, type Response, Router } from "express"; @@ -35,11 +34,9 @@ export const proformasRouter = (params: StartParams) => { const deps = getInternal("customer-invoices", "proformas"); const issuedInvoicesServices = getService("self:issuedInvoices"); - const catalogServices = getService("self:catalogs"); const publicServices = { issuedInvoiceServices: issuedInvoicesServices, - catalogServices: catalogServices, }; const router: Router = Router({ mergeParams: true }); diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-domain.mapper.ts index 29216252..3c9fe0a9 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-domain.mapper.ts @@ -1,4 +1,3 @@ -import type { IPaymentMethodPublicServices, ITaxRegimePublicServices } from "@erp/catalogs/api"; import { DiscountPercentage, type MapperParamsType, SequelizeDomainMapper } from "@erp/core/api"; import { CurrencyCode, @@ -40,27 +39,12 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper< private _recipientMapper: SequelizeProformaRecipientDomainMapper; private _taxesMapper: SequelizeProformaTaxesDomainMapper; - private _paymentMethodCatalog: IPaymentMethodPublicServices; - private _taxRegimeCatalog: ITaxRegimePublicServices; - constructor(params: MapperParamsType) { super(); - const { paymentCatalog, taxRegimeCatalog } = params as { - paymentCatalog: IPaymentMethodPublicServices; - taxRegimeCatalog: ITaxRegimePublicServices; - }; - - this._paymentMethodCatalog = paymentCatalog; - this._taxRegimeCatalog = taxRegimeCatalog; - - if (!this._paymentMethodCatalog) { - throw new Error('paymentCatalog not defined ("SequelizeProformaDomainMapper")'); - } - - this._itemsMapper = new SequelizeProformaItemDomainMapper(params); + this._itemsMapper = new SequelizeProformaItemDomainMapper(); this._recipientMapper = new SequelizeProformaRecipientDomainMapper(); - this._taxesMapper = new SequelizeProformaTaxesDomainMapper(params); + this._taxesMapper = new SequelizeProformaTaxesDomainMapper(); } private _mapAttributesToDomain(raw: CustomerInvoiceModel, params?: MapperParamsType) { @@ -277,7 +261,6 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper< globalDiscountPercentage: attributes.globalDiscountPercentage!, paymentMethodId: attributes.paymentMethodId!, - taxRegimeCode: attributes.taxRegimeCode!, linkedInvoiceId: attributes.linkedInvoiceId!, // El id de la factura emitida (linked_invoice) se asigna al hacer issue() desde la proforma, no viene en el modelo de persistencia. @@ -292,7 +275,7 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper< } } - public mapToPersistence( + public async mapToPersistence( source: Proforma, params?: MapperParamsType ): Result { @@ -333,47 +316,7 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper< ...params, }); - // 4) Payment - let payment: { - id: string | null; - description: string | null; - } = { id: null, description: null }; - - if (source.hasPaymentMethod) { - const paymentId = source.paymentMethodId.unwrap(); - const paymentOrNot = this._paymentMethodCatalog.findById(paymentId.toString()); - - if (paymentOrNot.isSome()) { - const paymentItem = paymentOrNot.unwrap(); - - payment = { - id: paymentItem.id ?? null, - description: paymentItem.description ?? null, - }; - } - } - - // 5) Tax regime - let taxRegime: { - code: string | null; - description: string | null; - } = { code: null, description: null }; - - if (source.hasTaxRegime) { - const taxRegimeCode = source.taxRegimeCode.unwrap(); - const taxRegimeOrNot = this._taxRegimeCatalog.findBy(taxRegimeCode.toString()); - - if (taxRegimeOrNot.isSome()) { - const taxRegimeItem = taxRegimeOrNot.unwrap(); - - taxRegime = { - code: taxRegimeItem.code ?? null, - description: taxRegimeItem.description ?? null, - }; - } - } - - // 6) Si hubo errores de mapeo, devolvemos colección de validación + // 4) Si hubo errores de mapeo, devolvemos colección de validación if (errors.length > 0) { return Result.fail( new ValidationErrorCollection("Customer invoice mapping to persistence failed", errors) @@ -385,7 +328,7 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper< const allAmounts = source.totals(); // Da los totales ya calculados - const proformaValues: Partial = { + const proformaValues: CustomerInvoiceCreationAttributes = { // Identificación id: source.id.toPrimitive(), company_id: source.companyId.toPrimitive(), @@ -406,11 +349,8 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper< description: maybeToNullable(source.description, (description) => description), notes: maybeToNullable(source.notes, (v) => v.toPrimitive()), - payment_method_id: payment.id, - payment_method_description: payment.description, - - tax_regime_code: taxRegime.code, - tax_regime_description: taxRegime.description, + payment_method_id: maybeToNullable(source.paymentMethodId, (value) => value.toPrimitive()), + tax_regime_code: maybeToNullable(source.taxRegimeCode, (value) => value), subtotal_amount_value: allAmounts.subtotalAmount.value, subtotal_amount_scale: allAmounts.subtotalAmount.scale, @@ -453,7 +393,7 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper< }; return Result.ok( - proformaValues as CustomerInvoiceCreationAttributes + proformaValues satisfies CustomerInvoiceCreationAttributes ); } } diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-item-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-item-domain.mapper.ts index f39feac3..5077951a 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-item-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-item-domain.mapper.ts @@ -1,4 +1,3 @@ -import type { JsonTaxCatalogProvider } from "@erp/core"; import { DiscountPercentage, type MapperParamsType, @@ -36,21 +35,6 @@ export class SequelizeProformaItemDomainMapper extends SequelizeDomainMapper< CustomerInvoiceItemCreationAttributes, ProformaItem > { - private _taxCatalog!: JsonTaxCatalogProvider; - - constructor(params: MapperParamsType) { - super(); - const { taxCatalog } = params as { - taxCatalog: JsonTaxCatalogProvider; - }; - - if (!taxCatalog) { - throw new Error('taxCatalog not defined ("ProformaItemDomainMapper")'); - } - - this._taxCatalog = taxCatalog; - } - private mapAttributesToDomain( raw: CustomerInvoiceItemModel, params?: MapperParamsType diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-taxes-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-taxes-domain.mapper.ts index dcf7d17f..5980e6a4 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-taxes-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-taxes-domain.mapper.ts @@ -1,4 +1,3 @@ -import type { JsonTaxCatalogProvider } from "@erp/core"; import { type MapperParamsType, SequelizeDomainMapper, TaxPercentage } from "@erp/core/api"; import { UniqueID, type ValidationErrorDetail, maybeToNullable } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; @@ -26,21 +25,6 @@ export class SequelizeProformaTaxesDomainMapper extends SequelizeDomainMapper< CustomerInvoiceTaxCreationAttributes, IProformaTaxTotals > { - private taxCatalog!: JsonTaxCatalogProvider; - - constructor(params: MapperParamsType) { - super(); - const { taxCatalog } = params as { - taxCatalog: JsonTaxCatalogProvider; - }; - - if (!taxCatalog) { - throw new Error('taxCatalog not defined ("SequelizeProformaTaxesDomainMapper")'); - } - - this.taxCatalog = taxCatalog; - } - public mapToDomain( source: CustomerInvoiceTaxModel, params?: MapperParamsType diff --git a/modules/customer-invoices/src/common/dto/request/proformas/create-proforma.request.dto.ts b/modules/customer-invoices/src/common/dto/request/proformas/create-proforma.request.dto.ts index 2edf77f3..c202dda3 100644 --- a/modules/customer-invoices/src/common/dto/request/proformas/create-proforma.request.dto.ts +++ b/modules/customer-invoices/src/common/dto/request/proformas/create-proforma.request.dto.ts @@ -47,6 +47,7 @@ export const CreateProformaRequestSchema = z.object({ payment_method_id: z.uuid().nullable().optional(), payment_term_id: z.uuid().nullable().optional(), + tax_regime_code: z.string().nullable().optional(), items: z.array(CreateProformaItemRequestSchema), }); diff --git a/modules/customer-invoices/src/common/dto/shared/payment-method-ref.dto.ts b/modules/customer-invoices/src/common/dto/shared/payment-method-ref.dto.ts index 1ab009e9..94ad747e 100644 --- a/modules/customer-invoices/src/common/dto/shared/payment-method-ref.dto.ts +++ b/modules/customer-invoices/src/common/dto/shared/payment-method-ref.dto.ts @@ -2,6 +2,7 @@ import { z } from "zod/v4"; export const PaymentMethodRefSchema = z.object({ id: z.uuid(), + name: z.string(), description: z.string(), }); diff --git a/packages/rdx-ddd/src/helpers/extract-or-push-error.ts b/packages/rdx-ddd/src/helpers/extract-or-push-error.ts index 807e6402..fd7071b4 100644 --- a/packages/rdx-ddd/src/helpers/extract-or-push-error.ts +++ b/packages/rdx-ddd/src/helpers/extract-or-push-error.ts @@ -1,7 +1,6 @@ import type { Result } from "@repo/rdx-utils"; import { - DomainValidationError, type ValidationErrorDetail, isDomainValidationError, isValidationErrorCollection, @@ -16,53 +15,56 @@ import { * @returns El valor extraído si el resultado es exitoso, o undefined si es un fallo. * @template T - El tipo de dato esperado en el resultado exitoso. * @throws {Error} Si el resultado es un fallo y no es una instancia de DomainValidationError. - * @example - * const result = Result.ok(42); - * const value = extractOrPushError(result, 'some.path', []); - * console.log(value); // 42 - * const errorResult = Result.fail(new Error('Something went wrong')); - * const value = extractOrPushError(errorResult, 'some.path', []); - * console.log(value); // undefined - * // errors will contain [{ path: 'some.path', message: 'Something went wrong' }] - * - * @see Result - * @see DomainValidationError - * @see ValidationErrorDetail */ -export function extractOrPushError( - result: Result, + +type ResultFn = Result | (() => Result); + +export function extractOrPushError( + result: ResultFn, path: string, errors: ValidationErrorDetail[] ): T | undefined { - if (result.isFailure) { - const error = result.error; + const actualResult = typeof result === "function" ? result() : result; - if (isValidationErrorCollection(error)) { - // Copiar todos los detalles, rellenando path si falta + if (!actualResult.isFailure) { + return actualResult.data; + } - error.details?.forEach((detail) => { + const error = actualResult.error; + + if (isValidationErrorCollection(error)) { + if (error.details?.length) { + error.details.forEach((detail) => { errors.push({ ...detail, - path: path ?? detail.path, + path: detail.path ?? path, }); }); - } else if (isDomainValidationError(error)) { - errors.push({ - path, - message: error.detail, - value: (error as any).cause, // mantener la causa original - }); } else { - // Fallback genérico: Error desconocido tratado como validación simple errors.push({ path, - message: error.message ?? "Unknown error", + message: error.message ?? "Validation error", }); } return undefined; } - return result.data; + if (isDomainValidationError(error)) { + errors.push({ + path, + message: error.detail, + value: error.cause, + }); + + return undefined; + } + + errors.push({ + path, + message: error.message ?? "Unknown error", + }); + + return undefined; } diff --git a/packages/rdx-ddd/src/value-objects/country-code.ts b/packages/rdx-ddd/src/value-objects/country-code.ts new file mode 100644 index 00000000..3e0999ae --- /dev/null +++ b/packages/rdx-ddd/src/value-objects/country-code.ts @@ -0,0 +1,47 @@ +import { Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +import { translateZodValidationError } from "../helpers"; + +import { ValueObject } from "./value-object"; + +interface CountryCodeProps { + value: string; +} + +export class CountryCode extends ValueObject { + protected static validate(value: string) { + const schema = z + .string() + .trim() + .uppercase() + .regex(/^[A-Z]{2}$/, { + message: "Country code must be 2 uppercase letters", + }); + + return schema.safeParse(value); + } + + static create(code: string): Result { + const valueIsValid = CountryCode.validate(code); + + if (!valueIsValid.success) { + return Result.fail( + translateZodValidationError("CountryCode creation failed", valueIsValid.error) + ); + } + return Result.ok(new CountryCode({ value: valueIsValid.data })); + } + + getProps(): string { + return this.props.value; + } + + toPrimitive(): string { + return this.props.value; + } + + toString() { + return String(this.props.value); + } +} diff --git a/packages/rdx-ddd/src/value-objects/country-region-code.ts b/packages/rdx-ddd/src/value-objects/country-region-code.ts new file mode 100644 index 00000000..9a69531e --- /dev/null +++ b/packages/rdx-ddd/src/value-objects/country-region-code.ts @@ -0,0 +1,47 @@ +import { Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +import { translateZodValidationError } from "../helpers"; + +import { ValueObject } from "./value-object"; + +interface CountryRegionCodeProps { + value: string; +} + +export class CountryRegionCode extends ValueObject { + protected static validate(value: string) { + const schema = z + .string() + .trim() + .uppercase() + .regex(/^[A-Z]{2}-[A-Z0-9-]+$/, { + message: "Region code must follow ISO-3166-2 pattern COUNTRY-REGION", + }); + + return schema.safeParse(value); + } + + static create(code: string): Result { + const valueIsValid = CountryRegionCode.validate(code); + + if (!valueIsValid.success) { + return Result.fail( + translateZodValidationError("CountryRegionCode creation failed", valueIsValid.error) + ); + } + return Result.ok(new CountryRegionCode({ value: valueIsValid.data })); + } + + getProps(): string { + return this.props.value; + } + + toPrimitive(): string { + return this.props.value; + } + + toString() { + return String(this.props.value); + } +} diff --git a/packages/rdx-ddd/src/value-objects/index.ts b/packages/rdx-ddd/src/value-objects/index.ts index 132d8d1c..8e6520fc 100644 --- a/packages/rdx-ddd/src/value-objects/index.ts +++ b/packages/rdx-ddd/src/value-objects/index.ts @@ -1,5 +1,7 @@ export * from "./city"; export * from "./country"; +export * from "./country-code"; +export * from "./country-region-code"; export * from "./currency-code"; export * from "./email-address"; export * from "./language-code"; diff --git a/packages/rdx-ddd/src/value-objects/percentage.ts b/packages/rdx-ddd/src/value-objects/percentage.ts index 7d083156..66c4e536 100644 --- a/packages/rdx-ddd/src/value-objects/percentage.ts +++ b/packages/rdx-ddd/src/value-objects/percentage.ts @@ -107,7 +107,10 @@ export class Percentage extends ValueObject { } toPrimitive() { - return this.getProps(); + return { + value: this.value, + scale: this.scale, + }; } toNumber(): number {